feat(workflow): 工作流库管理工具与编写技能,修复运行时 UI 多处问题

- 新增 list_workflows / save_workflow 工具:归档式创建与覆盖,
  归档时完整结构校验、覆盖前备份可恢复、name 与目录名一致性检查
- 新增内置技能 workflow-authoring:WORKFLOW.md 编写指南 +
  init/validate 脚本 + 节点详解与三个完整示例,skill_hints 挂载
- deactivate_workflow 挪工具循环特判层并广播摘牌快照,
  修复模型自主退出后 slash 菜单与快捷卡片残留
- 工作流卡片消失改为退出动画:store exiting 状态保留快照播完动画
  再清空,QuickDock hasContent 动画期间保持展开
- 运行期 workflow 通知 visibility 修正为 compact(原误走用户消息渲染)
- 激活/退出通知文案中性化,不向模型透露内部操作路径
- 修复空闲对话刷新后输入栏 token 圆环无数值(bootstrapRoute 补拉取)
- 修复 slash 激活工作流后输入栏 "/" 残留(跳转新对话前落盘草稿)
- 侧边栏工作流导航图标放大至 20px
This commit is contained in:
JOJO 2026-08-21 23:40:06 +08:00
parent 4d9b709a9e
commit 8c1dd407df
23 changed files with 1181 additions and 29 deletions

View File

@ -0,0 +1,106 @@
---
name: workflow-authoring
description: 编写、创建或修改工作流workflow定义文件 WORKFLOW.md 时使用。工作流把一套既定流程(阶段、审核、分支、结束方式)存为可复用文档,激活后智能体按流程逐步推进。当用户要求「新建一个工作流」「写一个 xx 流程」「调整工作流结构(增删阶段/审核/分支)」,或需要排查工作流格式与校验问题时使用。附带初始化与校验脚本。
---
# 工作流Workflow文档编写指南
工作流 = 一份 `WORKFLOW.md`YAML frontmatter 描述节点拓扑markdown 正文写流程约定。核心理念:**结构在边上,自主在阶段内**——拓扑只约束阶段间流转,每个执行阶段内智能体自由工作,阶段完成后汇报推进。
## 标准创建流程
1. **初始化模板**
```bash
python3 scripts/init_workflow.py <name> [--path <目录>] [--template linear|review|branch]
```
生成 `<目录>/<name>/WORKFLOW.md`已存在不覆盖。三种模板linear 线性 / review 带审核 / branch 带分支。
2. **填写内容**:替换模板中所有 `[方括号]` 占位,按需增删节点。
3. **保存前自检**
```bash
python3 scripts/validate_workflow.py <name>
```
有 error 级问题必须全部修掉,否则保存会被拒绝。
4. 校验通过后交由保存流程(工作流名即 `<name>`,合法字符:小写字母/数字/连字符,首字符为字母或数字)。
## WORKFLOW.md 格式骨架
```markdown
---
name: my-pipeline
description: 一句话说明这个流程干什么
review_mode: active
max_stage_rounds: 20
end_conditions: 什么算完成
nodes:
- id: start-1
kind: start
name: 开始
next: explore
- id: explore
kind: stage
name: 代码探索
goal: 理解改动范围,产出影响面清单
instructions: 先读 git diff 总览,再按模块深入;禁止通读整文件。
next: verify-gate
- id: verify-gate
kind: review
name: 验证审核
prompt: 验证证据是否真实可复现
next: end-1
reject_to: explore
max_rejects: 3
- id: end-1
kind: end
name: 结束
---
## 工作方式
先理解再动手,禁止跳过验证。
## 验证方式
构建通过 + 相关测试通过。
## 结束方式
更新文档并汇报。
```
`---` 之后的正文会在激活时原文呈现给执行者——写各阶段通用的整体约定,别写节点级细节。
## frontmatter 字段
| 字段 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `name` | ✓ | — | 小写字母/数字/连字符 |
| `description` | 推荐 | `''` | 一句话用途,出现在流程选择菜单 |
| `review_mode` | | `active` | `active`:审核时可查证证据;`readonly`:审核只看汇报与记录 |
| `max_stage_rounds` | | `20` | 单阶段轮数上限;撞限不停流程,只会让执行者停下来向用户确认是否继续 |
| `end_conditions` | | `''` | 完成标准的声明性描述(展示用) |
| `nodes` | ✓ | — | 节点数组,五种节点见 references/nodes.md |
## 节点速查
| kind | 语义 | 关键字段 |
|---|---|---|
| `start` | 入口,恰好 1 个 | `next` |
| `end` | 终点,至少 1 个 | — |
| `stage` | 执行阶段(阶段内完全自主) | `goal` + `instructions` + `next` |
| `review` | 审核把关(汇报时同步审完立即走,不停留) | `prompt` + `next` + `reject_to` + `max_rejects` |
| `branch` | 单出线=并线器自动穿过;多出线=AI 决策点(`condition` 必填为决策依据) | `next[]`target/condition |
**字段详解与语义**:见 [references/nodes.md](references/nodes.md)
**完整示例**(线性 / 带审核 / 分支+并线):见 [references/examples.md](references/examples.md)
## 校验规则违反即保存失败validate 脚本同款)
1. `name` 非空且合法;节点 id 全局唯一
2. 恰好 1 个 start至少 1 个 end
3. start/stage/review 的 `next`、review 的 `reject_to`、branch 每条出线的 `target` 都必须显式连接、指向存在的节点、且不指向 start
4. `max_rejects ≥ 1`;多出线 branch 的每条 `condition` 必填
## 写作建议
- 阶段 3~7 个为宜;`goal` 一句话、`instructions` 三五条关键约束(这两者在每次推进时会全文呈现给执行者)
- 需要把关就显式放 review 节点——终点前不会自动审核
- `reject_to` 指回真正能整改问题的阶段,不要指回紧邻前一站走过场
- 分支 `condition` 写给 AI 的决策依据:互斥、可判断、覆盖常见情形
- 修改已激活的工作流文档不影响正在运行的实例——运行中的实例按激活时的版本执行,需退出后重新激活才用新版本

View File

@ -0,0 +1,195 @@
# 完整示例
三个典型结构的完整 WORKFLOW.md可直接复制改造。目录线性无审核→ 单审核 → 分支+并线+审核。
## 1. 线性流程(调研报告)
最小可用结构start → 阶段链 → end。适合步骤固定、无需审核的收集整理类流程。
```markdown
---
name: research-report
description: 调研流程:收集资料、交叉验证、输出结构化报告
review_mode: active
max_stage_rounds: 25
end_conditions: 报告落盘且来源标注完整
nodes:
- id: start-1
kind: start
name: 开始
next: collect
- id: collect
kind: stage
name: 资料收集
goal: 围绕主题收集足够的一手资料
instructions: 优先官方来源;每条资料记录出处;不下结论。
next: verify
- id: verify
kind: stage
name: 交叉验证
goal: 剔除孤证与不可靠信息
instructions: 关键事实至少两个独立来源;存疑内容明确标注。
next: report
- id: report
kind: stage
name: 输出报告
goal: 生成结构化调研报告
instructions: 结论先行,证据随后;每个结论标注来源。
next: end-1
- id: end-1
kind: end
name: 结束
---
## 工作方式
先广度收集,再深度验证,最后成文。
## 验证方式
关键事实双来源;报告结论均可回溯到证据。
## 结束方式
报告落盘并汇报要点。
```
## 2. 单审核流程(代码评审)
阶段链中嵌入 review 节点形成把关;驳回回到能整改的前序阶段。
```markdown
---
name: code-review-pipeline
description: 代码评审标准流程:探索改动、逐项评审、输出结构化报告
review_mode: active
max_stage_rounds: 20
end_conditions: 评审报告落盘且最终审核通过
nodes:
- id: start-1
kind: start
name: 开始
next: explore
- id: explore
kind: stage
name: 代码探索
goal: 理解改动范围与相关模块,产出影响面清单
instructions: 先读 git diff 总览,再按模块逐个深入;禁止通读整文件。
next: review
- id: review
kind: stage
name: 逐项评审
goal: 按 checklist 评审每个改动文件
instructions: 关注边界条件、错误处理、安全问题;每条意见标注文件与行号。
next: review-gate
- id: review-gate
kind: review
name: 评审审核
prompt: 检查是否遗漏边界条件和安全问题
next: report
reject_to: explore
max_rejects: 3
- id: report
kind: stage
name: 输出报告
goal: 生成结构化评审报告并落盘
instructions: 按「严重/建议/可选」三档组织;给出明确结论。
next: report-gate
- id: report-gate
kind: review
name: 报告审核
prompt: 报告结论是否与评审意见一致
next: end-1
reject_to: review
max_rejects: 3
- id: end-1
kind: end
name: 结束
---
## 工作方式
按阶段推进,每个阶段完成后汇报。
## 验证方式
每条评审意见有文件与行号;审核关注边界与安全。
## 结束方式
报告落盘并给出明确结论。
```
注意两个审核节点的 `reject_to` 选择:评审不充分 → 回到「代码探索」重新理解;报告问题 → 回到「逐项评审」,而不是机械地都回前一站。
## 3. 分支 + 并线 + 审核(功能开发)
多出线决策点AI 按 condition 选路)+ 单出线并线器(汇合后统一审核)。
```markdown
---
name: feature-development
description: 功能开发流程:需求理解、按策略分支实现、汇总验证、文档收尾
review_mode: active
max_stage_rounds: 30
end_conditions: 验证通过且文档更新完成
nodes:
- id: start-1
kind: start
name: 开始
next: understand
- id: understand
kind: stage
name: 需求理解
goal: 明确需求边界与涉及模块
instructions: 产出改动清单与影响面;不确定处先问用户。
next: impl-branch
- id: impl-branch
kind: branch
name: 实现策略
next:
- target: prototype
condition: 方案不确定或风险高,先低成本验证
- target: full-impl
condition: 需求明确、改动范围清晰,直接正式实现
- id: prototype
kind: stage
name: 快速原型
goal: 最小成本验证方案可行性
instructions: 只写关键路径,不做边界打磨。
next: merge
- id: full-impl
kind: stage
name: 完整实现
goal: 完成正式代码修改
instructions: 小步快跑,优先根治不打补丁。
next: merge
- id: merge
kind: branch
name: 汇总
next:
- target: verify-gate
condition: ''
- id: verify-gate
kind: review
name: 验证审核
prompt: 验证证据是否真实可复现:构建与测试输出必须实际运行过
next: document
reject_to: impl-branch
max_rejects: 3
- id: document
kind: stage
name: 文档收尾
goal: 更新相关文档
instructions: 同步项目说明与相关文档。
next: end-1
- id: end-1
kind: end
name: 结束
---
## 工作方式
先理解再动手,禁止跳过验证。
## 验证方式
构建通过 + 相关测试通过。
## 结束方式
更新文档并汇报。
```
设计要点:分支的两条 `condition` 互斥可判断;并线器 `merge` 让两条实现路径汇合后共用同一套审核与收尾;审核驳回回到「实现策略」决策点,允许换条路重新实现。

View File

@ -0,0 +1,98 @@
# 节点详解(五种 kind
工作流拓扑为**串行**(无并行语义)。所有路由必须显式连接——`next` 留空会导致保存校验失败;任何路由不能指向 start 节点。
## start入口恰好 1 个)
```yaml
- id: start-1
kind: start
name: 开始
next: explore # 必填:第一个节点的 id
```
## end终点至少 1 个)
```yaml
- id: end-1
kind: end
name: 结束
```
`next`。流程推进到 end 即完成,执行者收到「输出总结后结束」的指令。可以有多个 end不同路径收束到各自终点
## stage执行阶段
```yaml
- id: explore
kind: stage
name: 代码探索
goal: 理解改动范围,产出影响面清单
instructions: 先读 git diff 总览,再按模块深入;禁止通读整文件。
next: verify-gate # 必填:可指向 stage/review/branch/end
```
- 阶段内执行者**完全自主**:自由调用工具、跑命令、读写文件;拓扑不约束阶段内行为
- `goal` 一句话目标;`instructions` 具体要求——两者在激活与每次推进时**全文呈现**给执行者,写关键约束,控制长度
- 审核节点把关时,`goal`/`instructions` 是「这个阶段应该做到什么」的标尺,写可检查的要求
- `max_stage_rounds` 限制单阶段轮数;撞限不中断流程,执行者会停下来向用户确认是否继续
## review审核节点
```yaml
- id: verify-gate
kind: review
name: 验证审核
prompt: 验证证据是否真实可复现:构建与测试输出必须实际运行过
next: document # 必填:通过后的去向
reject_to: explore # 必填:驳回后回到哪里整改
max_rejects: 3 # 连续驳回上限,默认 3必须 ≥ 1
```
- **瞬态节点**:阶段汇报到达时同步完成审核,通过/驳回立即走到下一站,流程不会在 review 上停留
- `prompt` 是写给审核者的**关注点**(重点查什么),不是流程描述;审核者能看到阶段执行痕迹与执行者的汇报
- 连续驳回达到 `max_rejects`,整个工作流以 failed 终止
- 审核服务异常按驳回处理(计入驳回计数)
- 想要「结束前总审核」:在 end 前显式放一个 review 节点,终点没有隐藏审核
## branch分支 / 并线器)
```yaml
# 多出线 = AI 决策点
- id: strategy
kind: branch
name: 策略选择
next:
- target: prototype
condition: 方案不确定或风险高,先低成本验证
- target: full-impl
condition: 需求明确、改动范围清晰,直接正式实现
# 单出线 = 并线器(自动穿过)
- id: merge
kind: branch
name: 汇总
next:
- target: verify-gate
condition: ''
```
- **多出线**:流程推进到此停下,执行者根据各出线的 `condition` 自主选择一条路径。`condition` 必填,是写给 AI 的决策依据——条件间互斥、可判断、覆盖常见情形
- **单出线**:并线器,自动穿过不做决策,`condition` 留空;典型用途是多条路径汇合后统一进 review 或 end
- 分支可以指向分支(连续决策),也可以回指形成循环(配合 review 驳回实现整改闭环)
## 通用字段说明
| 字段 | 说明 |
|---|---|
| `id` | 节点唯一标识(路由引用用它),建议 kebab-case |
| `name` | 显示名(进度展示用),可中文 |
| `kind` | 五种之一,缺省会按 stage 解析但应始终显式写 |
| `position` | 画布坐标,仅可视化编辑器使用;手写可省略,编辑器打开时会自动排版 |
## 语义速记(影响拓扑设计)
- 流程推进是「汇报驱动」:执行者完成当前阶段后主动汇报,引擎按当前阶段的 `next` 走向下一站
- review 驳回形成「整改循环」:`reject_to` 指回能真正整改的阶段(不一定是紧邻前一站)
- 已激活的运行实例按激活时的文档版本执行;修改文档后需重新激活才生效
- 工作流是执行者的辅助流程而非宿主:运行期间用户可自由穿插讨论,也可随时退出

View File

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Workflow Initializer - 生成新工作流的 WORKFLOW.md 模板
Usage:
init_workflow.py <name> [--path <目录>] [--template linear|review|branch]
Examples:
init_workflow.py my-pipeline
init_workflow.py code-check --path ./drafts --template review
init_workflow.py feature-flow --template branch
模板说明
linear 线性流程start stage stage end
review 带审核在实现阶段后加审核节点驳回回到整改阶段
branch 带分支策略分支双出线决策点+ 并线器 + 审核
"""
import argparse
import re
import sys
from pathlib import Path
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
HEADER = """---
name: {name}
description: [一句话说明这个流程干什么]
review_mode: active
max_stage_rounds: 20
end_conditions: [什么算完成验证标准与收尾动作]
nodes:
"""
FOOTER = """---
## 工作方式
[整体工作原则各阶段通用的约束]
## 验证方式
[如何验证成果要跑什么命令/检查什么证据]
## 结束方式
[收尾动作文档汇报等]
"""
TEMPLATES = {
"linear": """ - id: start-1
kind: start
name: 开始
next: step-1
- id: step-1
kind: stage
name: 阶段一
goal: [阶段目标一句话]
instructions: [阶段要求与关键约束]
next: step-2
- id: step-2
kind: stage
name: 阶段二
goal: [阶段目标一句话]
instructions: [阶段要求与关键约束]
next: end-1
- id: end-1
kind: end
name: 结束
""",
"review": """ - id: start-1
kind: start
name: 开始
next: implement
- id: implement
kind: stage
name: 实现
goal: [阶段目标一句话]
instructions: [阶段要求与关键约束]
next: verify-gate
- id: fix
kind: stage
name: 整改
goal: 按审核意见修复问题
instructions: 逐条处理审核意见说明每条的处置方式
next: verify-gate
- id: verify-gate
kind: review
name: 验证审核
prompt: [审核关注点重点检查什么]
next: end-1
reject_to: fix
max_rejects: 3
- id: end-1
kind: end
name: 结束
""",
"branch": """ - id: start-1
kind: start
name: 开始
next: prepare
- id: prepare
kind: stage
name: 准备
goal: [阶段目标一句话]
instructions: [阶段要求与关键约束]
next: strategy
- id: strategy
kind: branch
name: 策略选择
next:
- target: path-a
condition: [ A 路径的条件写给 AI 的决策依据]
- target: path-b
condition: [ B 路径的条件 A 互斥]
- id: path-a
kind: stage
name: 路径 A
goal: [阶段目标]
instructions: [阶段要求]
next: merge
- id: path-b
kind: stage
name: 路径 B
goal: [阶段目标]
instructions: [阶段要求]
next: merge
- id: merge
kind: branch
name: 汇总
next:
- target: verify-gate
condition: ''
- id: verify-gate
kind: review
name: 验证审核
prompt: [审核关注点]
next: end-1
reject_to: strategy
max_rejects: 3
- id: end-1
kind: end
name: 结束
""",
}
def main() -> int:
parser = argparse.ArgumentParser(description="生成新工作流的 WORKFLOW.md 模板")
parser.add_argument("name", help="工作流名(小写字母/数字/连字符)")
parser.add_argument("--path", default=".", help="目标父目录(默认当前目录)")
parser.add_argument("--template", default="linear", choices=sorted(TEMPLATES), help="模板类型")
args = parser.parse_args()
name = args.name.strip()
if not NAME_RE.match(name):
print(f"错误:工作流名不合法 {name!r}(仅限小写字母/数字/连字符,首字符为字母或数字)")
return 1
target_dir = Path(args.path).expanduser().resolve() / name
target_file = target_dir / "WORKFLOW.md"
if target_file.exists():
print(f"错误:{target_file} 已存在(不会覆盖)")
return 1
target_dir.mkdir(parents=True, exist_ok=True)
content = HEADER.format(name=name) + TEMPLATES[args.template] + FOOTER
target_file.write_text(content, encoding="utf-8")
print(f"已创建 {target_file}")
print("下一步:填写 [方括号] 占位内容,然后运行 validate_workflow.py 自检。")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Workflow Validator - 校验 WORKFLOW.md 的格式与结构
Usage:
validate_workflow.py <WORKFLOW.md 或其所在目录>
校验内容
- YAML frontmatter 存在且可解析name 为合法 slug
- review_mode / max_stage_rounds 取值合法
- 节点id 唯一恰好 1 start至少 1 end
- kind 字段齐全stage/review/branch/start 的必填项
- 路由显式连接指向存在的节点不指向 start
- max_rejects 1 的整数
退出码0 = 通过1 = error 级问题
保存工作流时服务端会做同等校验本脚本用于保存前自检
"""
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("错误:需要 PyYAMLpip install pyyaml")
sys.exit(1)
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
NODE_KINDS = ("start", "end", "stage", "review", "branch")
REVIEW_MODES = ("active", "readonly")
def locate(path_str: str) -> Path:
path = Path(path_str).expanduser().resolve()
if path.is_dir():
path = path / "WORKFLOW.md"
return path
def validate(text: str) -> list:
errors = []
match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.DOTALL)
if not match:
return ["WORKFLOW.md 缺少 YAML frontmatter文件必须以 --- 包裹的 YAML 开头)"]
try:
meta = yaml.safe_load(match.group(1))
except yaml.YAMLError as exc:
return [f"YAML frontmatter 解析失败:{exc}"]
if not isinstance(meta, dict):
return ["YAML frontmatter 必须是键值对结构"]
name = str(meta.get("name") or "").strip()
if not name:
errors.append("缺少 name")
elif not NAME_RE.match(name):
errors.append(f"name 不合法:{name!r}(仅限小写字母/数字/连字符,首字符为字母或数字)")
review_mode = meta.get("review_mode")
if review_mode is not None and review_mode not in REVIEW_MODES:
errors.append(f"review_mode 只能是 active 或 readonly当前 {review_mode!r}")
rounds = meta.get("max_stage_rounds")
if rounds is not None and (not isinstance(rounds, int) or rounds < 1):
errors.append(f"max_stage_rounds 必须是 ≥1 的整数(当前 {rounds!r}")
nodes = meta.get("nodes") or []
if not nodes:
errors.append("至少需要一个开始节点和一个结束节点")
return errors
by_id = {}
for n in nodes:
if not isinstance(n, dict):
errors.append("nodes 数组中每个元素必须是键值对结构")
continue
nid = n.get("id")
if not nid:
errors.append(f"存在缺少 id 的节点kind={n.get('kind')!r}")
continue
if nid in by_id:
errors.append(f"节点 id 重复:{nid}")
by_id[nid] = n
if n.get("kind") not in NODE_KINDS:
errors.append(f"节点 {nid} 的 kind 非法:{n.get('kind')!r}(可选 {NODE_KINDS}")
starts = [n for n in by_id.values() if n.get("kind") == "start"]
ends = [n for n in by_id.values() if n.get("kind") == "end"]
if len(starts) == 0:
errors.append("缺少开始节点")
elif len(starts) > 1:
errors.append(f"开始节点只能有一个(当前 {len(starts)} 个)")
if not ends:
errors.append("缺少结束节点")
def check_ref(owner, target, label):
if target is None or target == "":
errors.append(f"{label}未连接")
elif target not in by_id:
errors.append(f"{label}指向不存在的节点:{target}")
elif by_id[target].get("kind") == "start":
errors.append(f"{label}不能指向开始节点")
for n in by_id.values():
kind = n.get("kind")
label_name = n.get("name") or n.get("id")
if kind == "start":
check_ref(n, n.get("next"), "开始节点")
elif kind == "stage":
if not str(n.get("goal") or "").strip():
errors.append(f"阶段「{label_name}」缺少 goal")
if not str(n.get("instructions") or "").strip():
errors.append(f"阶段「{label_name}」缺少 instructions")
check_ref(n, n.get("next"), f"阶段「{label_name}")
elif kind == "review":
if not str(n.get("prompt") or "").strip():
errors.append(f"审核「{label_name}」缺少 prompt审核关注点")
check_ref(n, n.get("next"), f"审核「{label_name}」的通过路由")
check_ref(n, n.get("reject_to"), f"审核「{label_name}」的驳回路由")
max_rejects = n.get("max_rejects", 3)
if not isinstance(max_rejects, int) or max_rejects < 1:
errors.append(f"审核「{label_name}」驳回上限 max_rejects 必须 ≥ 1")
elif kind == "branch":
routes = n.get("next") or []
if not routes:
errors.append(f"分支「{label_name}」没有任何出线")
for r in routes:
if not isinstance(r, dict):
errors.append(f"分支「{label_name}」的出线必须是 target/condition 结构")
continue
check_ref(n, r.get("target"), f"分支「{label_name}」的出线")
if len(routes) > 1:
for r in routes:
if isinstance(r, dict) and not str(r.get("condition") or "").strip():
errors.append(f"分支「{label_name}」多出线的 condition 必填AI 决策依据):→ {r.get('target')}")
return errors
def main() -> int:
if len(sys.argv) != 2:
print(__doc__)
return 1
target = locate(sys.argv[1])
if not target.exists():
print(f"错误:文件不存在 {target}")
return 1
errors = validate(target.read_text(encoding="utf-8"))
if errors:
print(f"校验未通过({len(errors)} 个问题):")
for e in errors:
print(f" - {e}")
return 1
print(f"校验通过:{target}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -57,5 +57,13 @@
"custom functionality", "package", "skill development" "custom functionality", "package", "skill development"
], ],
"hint": "检测到用户想要创建新的 skill。建议先阅读 skill-creator skill。" "hint": "检测到用户想要创建新的 skill。建议先阅读 skill-creator skill。"
},
"workflow-authoring": {
"keywords": [
"工作流", "workflow", "WORKFLOW.md", "流程编排", "审核节点", "分支节点",
"创建工作流", "新建工作流", "修改工作流", "工作流文档", "save_workflow",
"create workflow", "workflow pipeline", "review node", "branch node"
],
"hint": "检测到用户可能需要创建或修改工作流WORKFLOW.md。如果情况符合必须先阅读 workflow-authoring skill。"
} }
} }

View File

@ -1,13 +1,15 @@
"""工作流Workflow工具定义。 """工作流Workflow工具定义。
5 个主智能体工具定稿 docs/workflow_feature_plan.md §5 7 个主智能体工具
- activate_workflow / report_workflow_stage / choose_workflow_branch - 运行时定稿 docs/workflow_feature_plan.md §5activate_workflow /
- get_workflow_status / deactivate_workflow report_workflow_stage / choose_workflow_branch / get_workflow_status / deactivate_workflow
- 库管理list_workflows列表 / name 形态读原文/ save_workflow归档式创建与覆盖
handler 分布 handler 分布
- activate / get_status / deactivate tools_execution.py 常规链无需 sender - activate / get_status / list_workflows / save_workflow tools_execution.py 常规链无需 sender
- report_workflow_stage / choose_workflow_branch chat_flow_tool_loop.py 特判 - report_workflow_stage / choose_workflow_branch / deactivate_workflow
需要 sender 发审核进度事件conversation_id workspace chat_flow_tool_loop.py 特判需要 sender 发审核进度/摘牌广播conversation_id
workspacedeactivate 摘牌后需广播 {active: False} 让前端实时摘卡片
""" """
from typing import Any, Dict, List from typing import Any, Dict, List
@ -115,4 +117,52 @@ class ToolsDefinitionWorkflowToolsMixin:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "list_workflows",
"description": (
"列出全部可用工作流(内置示例 + 用户创建),返回名称、描述、来源与节点数。"
"当需要告诉用户有哪些工作流可激活、或激活前确认工作流名是否存在时调用。"
"传入 name 时改为返回该工作流的完整定义文档WORKFLOW.md 原文),"
"用于修改前读取现状。"
),
"parameters": {
"type": "object",
"properties": self._inject_intent({
"name": {
"type": "string",
"description": "可选。指定工作流名时返回其完整定义文档,而非列表。",
}
}),
},
},
},
{
"type": "function",
"function": {
"name": "save_workflow",
"description": (
"把已创建的工作流目录校验并归档到工作流库,使其可被 activate_workflow 激活。"
"先用 write_file 创建 <name>/WORKFLOW.md格式与编写规范阅读 "
"workflow-authoring 技能,内含初始化与自检脚本),再调用本工具归档。"
"目录名必须与 WORKFLOW.md 的 name 字段一致;校验不通过返回完整错误清单且不归档;"
"归档成功后源目录被移除。"
),
"parameters": {
"type": "object",
"properties": self._inject_intent({
"source_dir": {
"type": "string",
"description": "包含 WORKFLOW.md 的目录路径(工作区内,如 drafts/my-flow",
},
"overwrite": {
"type": "boolean",
"description": "目标已存在(含与内置同名)时是否覆盖。默认 false已存在则报错并提示。",
},
}),
"required": ["source_dir"],
},
},
},
] ]

View File

@ -219,6 +219,7 @@ class MainTerminalToolsExecutionMixin:
"save_webpage", "save_webpage",
"terminal_session", "terminal_session",
"create_skill", "create_skill",
"save_workflow",
} }
# 扩展的只读命令白名单(包含常用管道命令) # 扩展的只读命令白名单(包含常用管道命令)
@ -612,16 +613,62 @@ class MainTerminalToolsExecutionMixin:
"message": build_status_text(data_dir=self.data_dir, conversation_id=str(conversation_id)), "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]: def _handle_list_workflows_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
from server.workflow_flow import deactivate_workflow from modules.workflow_manager import list_workflows, read_workflow_markdown
conversation_id = getattr(self.context_manager, "current_conversation_id", None)
if not conversation_id: name = str(arguments.get("name") or "").strip()
return {"success": False, "error": "当前没有打开的对话。"} if name:
return deactivate_workflow( try:
data_dir=self.data_dir, text = read_workflow_markdown(name, self.data_dir)
conversation_id=str(conversation_id), except FileNotFoundError as exc:
reason=str(arguments.get("reason") or ""), return {"success": False, "error": str(exc)}
except ValueError as exc:
return {"success": False, "error": str(exc)}
return {"success": True, "workflow_name": name, "message": text}
items = list_workflows(self.data_dir)
if not items:
return {
"success": True,
"count": 0,
"workflows": [],
"message": "当前没有可用工作流。可阅读 workflow-authoring 技能后用 save_workflow 创建。",
}
lines = [f"可用工作流(共 {len(items)} 个):"]
for item in items:
src = "内置" if item.get("source") == "builtin" else "用户"
lines.append(
f"- {item.get('name')}{item.get('description') or '(无描述)'}"
f"{src}{item.get('nodeCount')} 节点)"
)
return {
"success": True,
"count": len(items),
"workflows": items,
"message": "\n".join(lines),
}
def _handle_save_workflow_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
from modules.workflow_manager import archive_workflow_directory
source = self._resolve_create_skill_source_dir(arguments.get("source_dir"))
if source is None:
return {"success": False, "error": "source_dir 不能为空"}
result = archive_workflow_directory(
source,
self.data_dir,
overwrite=bool(arguments.get("overwrite")),
) )
if result.get("success"):
note = "(覆盖已有版本)" if result.get("overwritten") else ""
if result.get("shadows_builtin"):
note = "(用户副本,遮蔽同名内置工作流)"
result["summary"] = (
f"已归档工作流:{result.get('workflow_name')}"
f"{result.get('node_count')} 节点){note}"
"可使用 activate_workflow 激活。"
)
return result
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"""
@ -1178,8 +1225,10 @@ class MainTerminalToolsExecutionMixin:
result = self._handle_activate_workflow_tool(arguments) result = self._handle_activate_workflow_tool(arguments)
elif tool_name == "get_workflow_status": elif tool_name == "get_workflow_status":
result = self._handle_get_workflow_status_tool(arguments) result = self._handle_get_workflow_status_tool(arguments)
elif tool_name == "deactivate_workflow": elif tool_name == "list_workflows":
result = self._handle_deactivate_workflow_tool(arguments) result = self._handle_list_workflows_tool(arguments)
elif tool_name == "save_workflow":
result = self._handle_save_workflow_tool(arguments)
elif tool_name in {"vlm_analyze", "ocr_image"}: elif tool_name in {"vlm_analyze", "ocr_image"}:
path = arguments.get("path") path = arguments.get("path")
prompt = arguments.get("prompt") prompt = arguments.get("prompt")

View File

@ -326,3 +326,123 @@ def delete_workflow(name: str, data_dir: str | Path | None) -> None:
raise ValueError("内置示例不可删除(可复制为用户工作流后删除副本)") raise ValueError("内置示例不可删除(可复制为用户工作流后删除副本)")
raise FileNotFoundError(f"工作流不存在:{name}") raise FileNotFoundError(f"工作流不存在:{name}")
shutil.rmtree(target_dir) shutil.rmtree(target_dir)
def read_workflow_markdown(name: str, data_dir: str | Path | None) -> str:
"""读取工作流 WORKFLOW.md 原文(用户库优先,其次内置)。供 list_workflows 工具 name 形态。"""
_safe_name(name)
user_root = infer_user_workflows_dir(data_dir)
candidates: List[Path] = []
if user_root:
candidates.append(_workflow_file(user_root, name))
candidates.append(_workflow_file(BUILTIN_WORKFLOWS_DIR, name))
for wf_file in candidates:
if wf_file.exists():
return wf_file.read_text(encoding="utf-8")
raise FileNotFoundError(f"工作流不存在:{name}")
def archive_workflow_directory(
source_dir: str | Path,
data_dir: str | Path | None,
*,
overwrite: bool = False,
) -> Dict[str, Any]:
"""把含有 WORKFLOW.md 的目录校验并归档到用户工作流库(对齐 archive_skill_directory
规则 save_workflow 工具设计定稿一致
- 目录名必须与 frontmatter name 字段一致
- overwrite=false 用户库已存在同名 报错与内置同名 报错提示将遮蔽内置
- 覆盖前先备份旧目录移动失败自动恢复
- 成功后源目录随 move 移除
"""
source = Path(source_dir).expanduser().resolve()
if not source.exists() or not source.is_dir():
return {"success": False, "error": "source_dir 不是目录"}
wf_file = source / WORKFLOW_FILENAME
if not wf_file.exists() or not wf_file.is_file():
return {"success": False, "error": f"目录中缺少 {WORKFLOW_FILENAME}"}
try:
wf = workflow_from_markdown(wf_file.read_text(encoding="utf-8"), "user")
except Exception as exc:
return {"success": False, "error": f"WORKFLOW.md 解析失败:{exc}"}
raw_name = str(wf.get("name") or "").strip()
try:
name = _safe_name(raw_name)
except ValueError as exc:
return {"success": False, "error": str(exc)}
if source.name != name:
return {
"success": False,
"error": f"目录名({source.name})必须与 WORKFLOW.md 的 name 字段({name})一致",
}
errors = validate_structure(wf)
if errors:
return {
"success": False,
"error": "结构校验未通过:" + "".join(errors),
"validation_errors": errors,
"workflow_name": name,
}
root = infer_user_workflows_dir(data_dir)
if not root:
return {"success": False, "error": "无法确定用户工作流库目录"}
root = root.resolve()
target = (root / name).resolve()
if not str(target).startswith(str(root)):
return {"success": False, "error": "非法路径"}
existed_user = target.exists()
existed_builtin = _workflow_file(BUILTIN_WORKFLOWS_DIR, name).exists()
if not overwrite:
if existed_user:
return {
"success": False,
"error": f"工作流「{name}」已存在。确认覆盖请设 overwrite=true。",
"already_exists": True,
"workflow_name": name,
}
if existed_builtin:
return {
"success": False,
"error": (
f"与内置工作流「{name}」同名。归档后将创建用户副本遮蔽内置版本,"
"确认请设 overwrite=true。"
),
"builtin_conflict": True,
"workflow_name": name,
}
backup: Optional[Path] = None
if existed_user:
backup = root / f".{name}.backup-{int(datetime.now().timestamp())}"
try:
target.rename(backup)
except Exception as exc:
return {"success": False, "error": f"覆盖前备份旧版本失败:{exc}"}
try:
shutil.move(str(source), str(target))
except Exception as exc:
if backup is not None and backup.exists() and not target.exists():
try:
backup.rename(target)
except Exception:
pass
return {"success": False, "error": f"归档移动失败:{exc}", "workflow_name": name}
if backup is not None and backup.exists():
try:
shutil.rmtree(backup)
except Exception:
pass
return {
"success": True,
"workflow_name": name,
"node_count": len(wf.get("nodes") or []),
"overwritten": existed_user,
"shadows_builtin": (not existed_user) and existed_builtin,
}

View File

@ -48,6 +48,11 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str,
# 确保运行期直接渲染与刷新后从历史加载行为相同。 # 确保运行期直接渲染与刷新后从历史加载行为相同。
if normalized in {"sub_agent", "background_command", "compression", "compression_handoff"}: if normalized in {"sub_agent", "background_command", "compression", "compression_handoff"}:
return {"visibility": "compact", "starts_work": False} return {"visibility": "compact", "starts_work": False}
# 工作流运行期通知(退出/柔性通知 inline 注入):延续当前工作段的紧凑通知,
# 与其他系统通知一致走 compact 渲染;闲时任务入口派发走 task_main 的
# _user_message_ui_defaultschat + starts_work=True开启新一轮工作段
if normalized == "workflow":
return {"visibility": "compact", "starts_work": False}
# 目标审核续命:开启新一轮工作。 # 目标审核续命:开启新一轮工作。
if normalized == "goal_review": if normalized == "goal_review":
return {"visibility": "compact", "starts_work": True} return {"visibility": "compact", "starts_work": True}

View File

@ -278,11 +278,12 @@ async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_se
async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, sender, workspace, messages, conversation_id: Optional[str]) -> str: 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。 """report_workflow_stage / choose_workflow_branch / deactivate_workflow 的工具循环特判 handler。
推进矩阵需要 sender审核进度事件workspace conversation_id 推进矩阵需要 sender审核进度事件workspace conversation_id
submit_plan 一样在工具循环层处理而非 tools_execution 常规链 submit_plan 一样在工具循环层处理而非 tools_execution 常规链
审核在工具内同步 awaithandler 支持长阻塞submit_plan 已验证该模式 审核在工具内同步 awaithandler 支持长阻塞submit_plan 已验证该模式
deactivate 摘牌后需要 sender 广播 {active: False} 快照前端实时摘卡片
推进成功后同步刷新 messages 里的工作流 system 当前位置不滞后 推进成功后同步刷新 messages 里的工作流 system 当前位置不滞后
""" """
from server import workflow_flow from server import workflow_flow
@ -297,7 +298,7 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments,
conversation_id=conversation_id, conversation_id=conversation_id,
summary=str(args.get("summary") or ""), summary=str(args.get("summary") or ""),
) )
else: elif function_name == "choose_workflow_branch":
result = await workflow_flow.handle_branch_choice( result = await workflow_flow.handle_branch_choice(
web_terminal=web_terminal, web_terminal=web_terminal,
data_dir=workspace.data_dir, data_dir=workspace.data_dir,
@ -305,6 +306,13 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments,
conversation_id=conversation_id, conversation_id=conversation_id,
target_node_id=str(args.get("target_node_id") or ""), target_node_id=str(args.get("target_node_id") or ""),
) )
else: # deactivate_workflow
result = workflow_flow.deactivate_workflow(
data_dir=workspace.data_dir,
conversation_id=conversation_id,
reason=str(args.get("reason") or ""),
sender=sender,
)
if isinstance(result, dict) and result.get("success"): if isinstance(result, dict) and result.get("success"):
workflow_flow.refresh_workflow_system_segment( workflow_flow.refresh_workflow_system_segment(
messages, data_dir=workspace.data_dir, conversation_id=conversation_id messages, data_dir=workspace.data_dir, conversation_id=conversation_id
@ -960,7 +968,7 @@ 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"): elif function_name in ("report_workflow_stage", "choose_workflow_branch", "deactivate_workflow"):
tool_result = await _handle_workflow_tool( tool_result = await _handle_workflow_tool(
function_name=function_name, function_name=function_name,
web_terminal=web_terminal, web_terminal=web_terminal,

View File

@ -647,13 +647,19 @@ async def run_stage_review(
# ---------------------------------------------------------------- 退出 / 通知文本 # ---------------------------------------------------------------- 退出 / 通知文本
def deactivate_workflow(*, data_dir, conversation_id: str, reason: str) -> Dict[str, Any]: def deactivate_workflow(*, data_dir, conversation_id: str, reason: str, sender=None) -> Dict[str, Any]:
"""模型自主退出deactivate_workflow 工具):摘牌,工具返回闭环,不发 user 通知。""" """模型自主退出deactivate_workflow 工具):摘牌,工具返回闭环,不发 user 通知。"""
if not conversation_id:
return {"success": False, "error": "当前没有打开的对话。"}
wsm = get_active_manager(data_dir, conversation_id) wsm = get_active_manager(data_dir, conversation_id)
if wsm is None: if wsm is None:
return {"success": False, "error": "当前对话没有激活的工作流。"} return {"success": False, "error": "当前对话没有激活的工作流。"}
name = str(wsm.state.get("workflow_name") or "") name = str(wsm.state.get("workflow_name") or "")
wsm.deactivate(status=STATUS_STOPPED, reason=REASON_MODEL) wsm.deactivate(status=STATUS_STOPPED, reason=REASON_MODEL)
# 摘牌后广播 {active: False} 快照(此时 progress_snapshot 只剩 active=False
# 前端实时摘除快捷窗口卡片与 slash 菜单「进行中」状态,
# 否则只能等下次刷新/切换对话静态校正才消失。
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
note = str(reason or "").strip() note = str(reason or "").strip()
return { return {
"success": True, "success": True,
@ -671,7 +677,7 @@ def deactivate_workflow_by_user(*, data_dir, conversation_id: str) -> Dict[str,
wsm.push_notice( wsm.push_notice(
notice_type="deactivated_by_user", notice_type="deactivated_by_user",
message=( message=(
f"用户已通过快捷操作退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;" f"用户已退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;"
"你可以继续自由工作。若用户之后要求恢复,可重新激活。" "你可以继续自由工作。若用户之后要求恢复,可重新激活。"
), ),
) )

View File

@ -152,7 +152,7 @@ def api_activate_workflow(terminal, workspace, username):
activation_text = str(result.get("text") or "") activation_text = str(result.get("text") or "")
prompt = ( prompt = (
"用户已通过快捷菜单激活工作流,请立即开始按流程执行。" "工作流已激活,请立即开始按流程执行。"
"完成当前步骤后调用 report_workflow_stage(summary) 汇报。\n\n" "完成当前步骤后调用 report_workflow_stage(summary) 汇报。\n\n"
f"{activation_text}" f"{activation_text}"
) )

View File

@ -87,6 +87,15 @@ export const modeMethods = {
if (!normalized || normalized === this.currentConversationId) { if (!normalized || normalized === this.currentConversationId) {
return; return;
} }
// 跳转前把当前输入内容slash 触发符已被 deleteSlashToken 删除)立即落盘。
// 草稿是全局单一份(/api/input-draft 不按对话隔离),而 enterConversation 会经
// currentConversationId watcher 触发 restoreComposerDraftState若只等 1s debounce
// 恢复拿到的仍是删除前的旧草稿,已删除的 "/" 会被重新写回输入框。
try {
await this.persistComposerDraftNow({ reason: 'workflow-activated', force: true });
} catch (_e) {
// 落盘失败不阻断进入对话
}
// 侧边栏列表先插入占位(对齐 send.ts 首条消息创建对话的行为),否则列表要待下次刷新才出现 // 侧边栏列表先插入占位(对齐 send.ts 首条消息创建对话的行为),否则列表要待下次刷新才出现
try { try {
const newPlaceholder = { const newPlaceholder = {

View File

@ -92,6 +92,11 @@ export const routeMethods = {
this.titleReady = true; this.titleReady = true;
this.suppressTitleTyping = false; this.suppressTitleTyping = false;
this.startTitleTyping(this.currentConversationTitle, { animate: false }); this.startTitleTyping(this.currentConversationTitle, { animate: false });
// 刷新路径不经 loadConversation需在此补拉 token 统计,
// 否则输入栏右下角上下文用量圆环保持 0enterConversation 内
// skipConversationHistoryReload 会让 watcher 也跳过拉取)
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
} else { } else {
history.replaceState({}, '', '/new'); history.replaceState({}, '', '/new');
this.currentConversationId = null; this.currentConversationId = null;

View File

@ -94,6 +94,10 @@ export function renderEnhancedToolResult(
return renderReadFile(result, args); return renderReadFile(result, args);
} else if (name === 'create_skill') { } else if (name === 'create_skill') {
return renderCreateSkill(result); return renderCreateSkill(result);
} else if (name === 'list_workflows') {
return renderListWorkflows(result, args);
} else if (name === 'save_workflow') {
return renderSaveWorkflow(result, args);
} else if (name === 'vlm_analyze') { } else if (name === 'vlm_analyze') {
return renderVlmAnalyze(result, args); return renderVlmAnalyze(result, args);
} else if (name === 'ocr_image') { } else if (name === 'ocr_image') {
@ -1164,6 +1168,82 @@ function renderCreateSkill(result: any): string {
return html; return html;
} }
function renderListWorkflows(result: any, args: any): string {
if (!result?.success) {
const error = result?.error ?? '查询失败';
return `<div class="tool-result-error">⚠️ ${escapeHtml(String(error))}</div>`;
}
// name 形态:展示该工作流的完整定义文档
const detailName = String(args?.name || result?.workflow_name || '');
if (detailName) {
let html = '<div class="tool-result-meta">';
html += `<div><strong>工作流:</strong>${escapeHtml(detailName)}</div>`;
html += '</div>';
const doc = String(result?.message || '');
if (doc) {
html += '<div class="tool-result-content scrollable">';
html += `<pre>${escapeHtml(doc)}</pre>`;
html += '</div>';
}
return html;
}
// 列表形态
const items = Array.isArray(result?.workflows) ? result.workflows : [];
if (items.length === 0) {
return '<div class="tool-result-empty">当前没有可用工作流。</div>';
}
let html = '<div class="tool-result-meta">';
html += `<div><strong>工作流数量:</strong>${items.length}</div>`;
html += '</div>';
html += '<div class="search-result-list">';
items.forEach((item: any) => {
const name = escapeHtml(String(item?.name || '未命名'));
const desc = escapeHtml(String(item?.description || '(无描述)'));
const source = item?.source === 'builtin' ? '内置' : '用户';
const nodeCount = Number(item?.nodeCount || 0);
html += '<div class="search-result-item">';
html += `<div class="search-result-title">${name}</div>`;
html += `<div>${desc}</div>`;
html += `<div><strong>来源:</strong>${source} | <strong>节点:</strong>${nodeCount}</div>`;
html += '</div>';
});
html += '</div>';
return html;
}
function renderSaveWorkflow(result: any, args: any): string {
const status = formatToolStatusLabel(result, '✓ 已归档');
let html = '<div class="tool-result-meta">';
html += `<div><strong>状态:</strong>${status}</div>`;
const sourceDir = String(args?.source_dir || '');
if (sourceDir) {
html += `<div><strong>源目录:</strong>${escapeHtml(sourceDir)}</div>`;
}
if (result?.workflow_name) {
html += `<div><strong>工作流:</strong>${escapeHtml(String(result.workflow_name))}</div>`;
}
if (result?.success && typeof result?.node_count === 'number') {
html += `<div><strong>节点数:</strong>${result.node_count}</div>`;
}
if (result?.success && result?.overwritten) {
html += '<div><strong>模式:</strong>覆盖已有版本</div>';
} else if (result?.success && result?.shadows_builtin) {
html += '<div><strong>模式:</strong>用户副本(遮蔽同名内置)</div>';
}
html += '</div>';
if (!result?.success && result?.error) {
html += `<div class="tool-result-error">${escapeHtml(String(result.error))}</div>`;
}
if (result?.success && result?.summary) {
html += '<div class="tool-result-content">';
html += escapeHtml(String(result.summary));
html += '</div>';
}
return html;
}
function formatPersonalizationFieldLabel(field: string): string { function formatPersonalizationFieldLabel(field: string): string {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
self_identify: 'AI 自称', self_identify: 'AI 自称',

View File

@ -401,6 +401,31 @@ watch(
{ flush: 'sync', immediate: true } { flush: 'sync', immediate: true }
); );
// 退deactivate 广 / slash 退store live active=false
// exiting 退 finishExit
// QuickDock hasContent exiting
watch(
() => workflowStore.exiting,
async (exiting) => {
if (!exiting) {
return;
}
const myGen = ++gen;
await playWindowExit(myGen);
if (myGen !== gen) {
// /退 snapshot watch
windowExiting.value = false;
return;
}
windowExiting.value = false;
rows.value = [];
footnote.value = null;
visible.value = false;
workflowStore.finishExit();
},
{ flush: 'sync' }
);
// //
watch( watch(
() => snapshot.value.footnote, () => snapshot.value.footnote,

View File

@ -178,8 +178,14 @@ export const useQuickDockStore = defineStore('quickDock', {
bgStore.commands.length > 0 || bgStore.commands.length > 0 ||
state.editedFiles.length > 0; state.editedFiles.length > 0;
// 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准; // 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准;
// 掩码关闭后纯真实状态 // 掩码关闭后纯真实状态。
return workflowStore.isActive || (state.assumedActive ? state.assumedContent : restReal); // workflowStore.exiting退出动画播放期间保持容器展开否则容器 300ms
// 收起会吞掉窗口自身的 240ms 退出动画(动画播完 finishExit 后才真正清空)
return (
workflowStore.isActive ||
workflowStore.exiting ||
(state.assumedActive ? state.assumedContent : restReal)
);
}, },
/** 实际处于展开态:有内容且未被用户手动收起 */ /** 实际处于展开态:有内容且未被用户手动收起 */
expanded(state): boolean { expanded(state): boolean {

View File

@ -44,6 +44,13 @@ interface WorkflowState {
snapshot: WorkflowSnapshot; snapshot: WorkflowSnapshot;
/** true = 来自任务期实时事件播动画false = 来自加载/恢复(静态呈现) */ /** true = 来自任务期实时事件播动画false = 来自加载/恢复(静态呈现) */
live: boolean; live: boolean;
/**
* 退 live active=falsedeactivate 广 / slash 退
* 退 finishExit
* QuickDock hasContent 300ms
* 240ms 退
*/
exiting: boolean;
} }
const emptySnapshot = (): WorkflowSnapshot => ({ const emptySnapshot = (): WorkflowSnapshot => ({
@ -60,7 +67,8 @@ const emptySnapshot = (): WorkflowSnapshot => ({
export const useWorkflowStore = defineStore('workflow', { export const useWorkflowStore = defineStore('workflow', {
state: (): WorkflowState => ({ state: (): WorkflowState => ({
snapshot: emptySnapshot(), snapshot: emptySnapshot(),
live: false live: false,
exiting: false
}), }),
getters: { getters: {
isActive(state): boolean { isActive(state): boolean {
@ -72,15 +80,40 @@ export const useWorkflowStore = defineStore('workflow', {
setWorkflow(snapshot: Partial<WorkflowSnapshot> | null | undefined, live = false) { setWorkflow(snapshot: Partial<WorkflowSnapshot> | null | undefined, live = false) {
this.live = live; this.live = live;
if (!snapshot || snapshot.active !== true) { if (!snapshot || snapshot.active !== true) {
// 退出动画进行中再收到停用事件(如 slash 退出后摘牌广播又到):忽略,
// 等动画播完由 finishExit 一次性清空。
// 例外:静态校正(切换对话/刷新恢复 live=false优先直接清空并打断动画
if (this.exiting) {
if (!live) {
this.exiting = false;
this.snapshot = emptySnapshot();
}
return;
}
// live 事件驱动的消失:标记 exiting 保留快照,窗口播退出动画;
// 静态校正(切换对话/刷新恢复 live=false瞬间清空不播动画
if (live && this.snapshot.active) {
this.exiting = true;
return;
}
this.snapshot = emptySnapshot(); this.snapshot = emptySnapshot();
return; return;
} }
// 新活跃快照到达:中止可能进行中的退出流程(快速重新激活场景)
this.exiting = false;
this.snapshot = { ...emptySnapshot(), ...snapshot, active: true }; this.snapshot = { ...emptySnapshot(), ...snapshot, active: true };
}, },
/** 窗口退出动画播完后由 WorkflowWindow 调用:真正清空状态 */
finishExit() {
this.exiting = false;
this.live = false;
this.snapshot = emptySnapshot();
},
/** 对话切换 / 离开对话时清空 */ /** 对话切换 / 离开对话时清空 */
reset() { reset() {
this.snapshot = emptySnapshot(); this.snapshot = emptySnapshot();
this.live = false; this.live = false;
this.exiting = false;
}, },
/** /**
* live=false * live=false

View File

@ -180,8 +180,8 @@
} }
.workflow-icon svg { .workflow-icon svg {
width: 18px; width: 20px;
height: 18px; height: 20px;
} }
.monitor-mode-btn.blocked { .monitor-mode-btn.blocked {

View File

@ -10,7 +10,10 @@ const COMPACT_FALLBACK_SOURCES = new Set([
'compression', 'compression',
'compression_handoff', 'compression_handoff',
'sub_agent', 'sub_agent',
'background_command' 'background_command',
// 工作流运行期通知inline 注入):紧凑通知;闲时任务入口派发的激活/退出
// 消息带显式 visibility:'chat',不受本兜底影响
'workflow'
]); ]);
function normalizeSource(value: any): string { function normalizeSource(value: any): string {

View File

@ -52,6 +52,8 @@ from utils.tool_result_formatter.web_media import (
_format_trigger_easter_egg, _format_trigger_easter_egg,
_format_create_skill, _format_create_skill,
_format_manage_personalization, _format_manage_personalization,
_format_list_workflows,
_format_save_workflow,
) )
from utils.tool_result_formatter.common import _format_failure from utils.tool_result_formatter.common import _format_failure
@ -133,6 +135,8 @@ TOOL_FORMATTERS = {
"todo_update_task": _format_todo_update_task, "todo_update_task": _format_todo_update_task,
"update_memory": _format_update_memory, "update_memory": _format_update_memory,
"create_skill": _format_create_skill, "create_skill": _format_create_skill,
"list_workflows": _format_list_workflows,
"save_workflow": _format_save_workflow,
"manage_personalization": _format_manage_personalization, "manage_personalization": _format_manage_personalization,
"create_sub_agent": _format_create_sub_agent, "create_sub_agent": _format_create_sub_agent,
"close_sub_agent": _format_close_sub_agent, "close_sub_agent": _format_close_sub_agent,

View File

@ -13,6 +13,17 @@ def _format_create_skill(result_data: Dict[str, Any]) -> str:
] ]
return "\n".join(lines) return "\n".join(lines)
def _format_list_workflows(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("list_workflows", result_data)
# message 已是格式化清单(无参形态)或 WORKFLOW.md 原文name 形态),完整透传
return str(result_data.get("message") or "")
def _format_save_workflow(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("save_workflow", result_data)
return str(result_data.get("summary") or f"已归档工作流:{result_data.get('workflow_name') or '未命名'}")
def _format_extract_webpage(result_data: Dict[str, Any]) -> str: def _format_extract_webpage(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"): if not result_data.get("success"):
return _format_failure("extract_webpage", result_data) return _format_failure("extract_webpage", result_data)