diff --git a/agentskills/workflow-authoring/SKILL.md b/agentskills/workflow-authoring/SKILL.md new file mode 100644 index 00000000..dd92966d --- /dev/null +++ b/agentskills/workflow-authoring/SKILL.md @@ -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 [--path <目录>] [--template linear|review|branch] + ``` + 生成 `<目录>//WORKFLOW.md`(已存在不覆盖)。三种模板:linear 线性 / review 带审核 / branch 带分支。 +2. **填写内容**:替换模板中所有 `[方括号]` 占位,按需增删节点。 +3. **保存前自检**: + ```bash + python3 scripts/validate_workflow.py + ``` + 有 error 级问题必须全部修掉,否则保存会被拒绝。 +4. 校验通过后交由保存流程(工作流名即 ``,合法字符:小写字母/数字/连字符,首字符为字母或数字)。 + +## 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 的决策依据:互斥、可判断、覆盖常见情形 +- 修改已激活的工作流文档不影响正在运行的实例——运行中的实例按激活时的版本执行,需退出后重新激活才用新版本 diff --git a/agentskills/workflow-authoring/references/examples.md b/agentskills/workflow-authoring/references/examples.md new file mode 100644 index 00000000..7a865227 --- /dev/null +++ b/agentskills/workflow-authoring/references/examples.md @@ -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` 让两条实现路径汇合后共用同一套审核与收尾;审核驳回回到「实现策略」决策点,允许换条路重新实现。 diff --git a/agentskills/workflow-authoring/references/nodes.md b/agentskills/workflow-authoring/references/nodes.md new file mode 100644 index 00000000..c8b2b92e --- /dev/null +++ b/agentskills/workflow-authoring/references/nodes.md @@ -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` 指回能真正整改的阶段(不一定是紧邻前一站) +- 已激活的运行实例按激活时的文档版本执行;修改文档后需重新激活才生效 +- 工作流是执行者的辅助流程而非宿主:运行期间用户可自由穿插讨论,也可随时退出 diff --git a/agentskills/workflow-authoring/scripts/init_workflow.py b/agentskills/workflow-authoring/scripts/init_workflow.py new file mode 100644 index 00000000..5906d1d4 --- /dev/null +++ b/agentskills/workflow-authoring/scripts/init_workflow.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Workflow Initializer - 生成新工作流的 WORKFLOW.md 模板 + +Usage: + init_workflow.py [--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()) diff --git a/agentskills/workflow-authoring/scripts/validate_workflow.py b/agentskills/workflow-authoring/scripts/validate_workflow.py new file mode 100644 index 00000000..c2399741 --- /dev/null +++ b/agentskills/workflow-authoring/scripts/validate_workflow.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Workflow Validator - 校验 WORKFLOW.md 的格式与结构 + +Usage: + validate_workflow.py + +校验内容: + - 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("错误:需要 PyYAML(pip 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()) diff --git a/config/skill_hints.json b/config/skill_hints.json index 211f2d89..24d82f60 100644 --- a/config/skill_hints.json +++ b/config/skill_hints.json @@ -57,5 +57,13 @@ "custom functionality", "package", "skill development" ], "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。" } } diff --git a/core/main_terminal_parts/tools_definition/workflow_tools.py b/core/main_terminal_parts/tools_definition/workflow_tools.py index 687c425f..623bfb09 100644 --- a/core/main_terminal_parts/tools_definition/workflow_tools.py +++ b/core/main_terminal_parts/tools_definition/workflow_tools.py @@ -1,13 +1,15 @@ """工作流(Workflow)工具定义。 -5 个主智能体工具(定稿 docs/workflow_feature_plan.md §5): -- activate_workflow / report_workflow_stage / choose_workflow_branch -- get_workflow_status / deactivate_workflow +7 个主智能体工具: +- 运行时(定稿 docs/workflow_feature_plan.md §5):activate_workflow / + report_workflow_stage / choose_workflow_branch / get_workflow_status / deactivate_workflow +- 库管理:list_workflows(列表 / name 形态读原文)/ save_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) +- activate / get_status / list_workflows / save_workflow 走 tools_execution.py 常规链(无需 sender) +- report_workflow_stage / choose_workflow_branch / deactivate_workflow 走 + chat_flow_tool_loop.py 特判(需要 sender 发审核进度/摘牌广播、conversation_id + 与 workspace;deactivate 摘牌后需广播 {active: False} 让前端实时摘卡片) """ 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 创建 /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"], + }, + }, + }, ] diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 255eec09..6da9ed44 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -219,6 +219,7 @@ class MainTerminalToolsExecutionMixin: "save_webpage", "terminal_session", "create_skill", + "save_workflow", } # 扩展的只读命令白名单(包含常用管道命令) @@ -612,16 +613,62 @@ class MainTerminalToolsExecutionMixin: "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_list_workflows_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + from modules.workflow_manager import list_workflows, read_workflow_markdown + + name = str(arguments.get("name") or "").strip() + if name: + try: + text = read_workflow_markdown(name, self.data_dir) + except FileNotFoundError as exc: + 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]: """处理 update_project_memory:写入 .astrion/memory/{name}.md""" @@ -1178,8 +1225,10 @@ class MainTerminalToolsExecutionMixin: 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 == "list_workflows": + 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"}: path = arguments.get("path") prompt = arguments.get("prompt") diff --git a/modules/workflow_manager.py b/modules/workflow_manager.py index b81c1595..c0eacdad 100644 --- a/modules/workflow_manager.py +++ b/modules/workflow_manager.py @@ -326,3 +326,123 @@ def delete_workflow(name: str, data_dir: str | Path | None) -> None: raise ValueError("内置示例不可删除(可复制为用户工作流后删除副本)") raise FileNotFoundError(f"工作流不存在:{name}") 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, + } diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 9808ae4e..779266f0 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -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"}: return {"visibility": "compact", "starts_work": False} + # 工作流运行期通知(退出/柔性通知 inline 注入):延续当前工作段的紧凑通知, + # 与其他系统通知一致走 compact 渲染;闲时任务入口派发走 task_main 的 + # _user_message_ui_defaults(chat + starts_work=True,开启新一轮工作段)。 + if normalized == "workflow": + return {"visibility": "compact", "starts_work": False} # 目标审核续命:开启新一轮工作。 if normalized == "goal_review": return {"visibility": "compact", "starts_work": True} diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 1710ef73..1d76c1ba 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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: - """report_workflow_stage / choose_workflow_branch 的工具循环特判 handler。 + """report_workflow_stage / choose_workflow_branch / deactivate_workflow 的工具循环特判 handler。 推进矩阵需要 sender(审核进度事件)、workspace 与 conversation_id, 与 submit_plan 一样在工具循环层处理而非 tools_execution 常规链。 审核在工具内同步 await(handler 支持长阻塞,submit_plan 已验证该模式)。 + deactivate 摘牌后需要 sender 广播 {active: False} 快照(前端实时摘卡片)。 推进成功后同步刷新 messages 里的工作流 system 段(当前位置不滞后)。 """ from server import workflow_flow @@ -297,7 +298,7 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, conversation_id=conversation_id, summary=str(args.get("summary") or ""), ) - else: + elif function_name == "choose_workflow_branch": result = await workflow_flow.handle_branch_choice( web_terminal=web_terminal, data_dir=workspace.data_dir, @@ -305,6 +306,13 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments, conversation_id=conversation_id, 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"): workflow_flow.refresh_workflow_system_segment( 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, 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( function_name=function_name, web_terminal=web_terminal, diff --git a/server/workflow_flow.py b/server/workflow_flow.py index 55158fd5..630d59c9 100644 --- a/server/workflow_flow.py +++ b/server/workflow_flow.py @@ -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 通知。""" + if not conversation_id: + return {"success": False, "error": "当前没有打开的对话。"} 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) + # 摘牌后广播 {active: False} 快照(此时 progress_snapshot 只剩 active=False): + # 前端实时摘除快捷窗口卡片与 slash 菜单「进行中」状态, + # 否则只能等下次刷新/切换对话静态校正才消失。 + emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id) note = str(reason or "").strip() return { "success": True, @@ -671,7 +677,7 @@ def deactivate_workflow_by_user(*, data_dir, conversation_id: str) -> Dict[str, wsm.push_notice( notice_type="deactivated_by_user", message=( - f"用户已通过快捷操作退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;" + f"用户已退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;" "你可以继续自由工作。若用户之后要求恢复,可重新激活。" ), ) diff --git a/server/workflow_runtime_api.py b/server/workflow_runtime_api.py index 1aa660ba..8eac6adc 100644 --- a/server/workflow_runtime_api.py +++ b/server/workflow_runtime_api.py @@ -152,7 +152,7 @@ def api_activate_workflow(terminal, workspace, username): activation_text = str(result.get("text") or "") prompt = ( - "用户已通过快捷菜单激活工作流,请立即开始按流程执行。" + "工作流已激活,请立即开始按流程执行。" "完成当前步骤后调用 report_workflow_stage(summary) 汇报。\n\n" f"{activation_text}" ) diff --git a/static/src/app/methods/ui/mode.ts b/static/src/app/methods/ui/mode.ts index ded54f73..1222843b 100644 --- a/static/src/app/methods/ui/mode.ts +++ b/static/src/app/methods/ui/mode.ts @@ -87,6 +87,15 @@ export const modeMethods = { if (!normalized || normalized === this.currentConversationId) { 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 首条消息创建对话的行为),否则列表要待下次刷新才出现 try { const newPlaceholder = { diff --git a/static/src/app/methods/ui/route.ts b/static/src/app/methods/ui/route.ts index 90859669..6813674e 100644 --- a/static/src/app/methods/ui/route.ts +++ b/static/src/app/methods/ui/route.ts @@ -92,6 +92,11 @@ export const routeMethods = { this.titleReady = true; this.suppressTitleTyping = false; this.startTitleTyping(this.currentConversationTitle, { animate: false }); + // 刷新路径不经 loadConversation,需在此补拉 token 统计, + // 否则输入栏右下角上下文用量圆环保持 0(enterConversation 内 + // skipConversationHistoryReload 会让 watcher 也跳过拉取) + this.fetchConversationTokenStatistics(); + this.updateCurrentContextTokens(); } else { history.replaceState({}, '', '/new'); this.currentConversationId = null; diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index d101ad9c..fe6be8ed 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -94,6 +94,10 @@ export function renderEnhancedToolResult( return renderReadFile(result, args); } else if (name === 'create_skill') { 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') { return renderVlmAnalyze(result, args); } else if (name === 'ocr_image') { @@ -1164,6 +1168,82 @@ function renderCreateSkill(result: any): string { return html; } +function renderListWorkflows(result: any, args: any): string { + if (!result?.success) { + const error = result?.error ?? '查询失败'; + return `
⚠️ ${escapeHtml(String(error))}
`; + } + + // name 形态:展示该工作流的完整定义文档 + const detailName = String(args?.name || result?.workflow_name || ''); + if (detailName) { + let html = '
'; + html += `
工作流:${escapeHtml(detailName)}
`; + html += '
'; + const doc = String(result?.message || ''); + if (doc) { + html += '
'; + html += `
${escapeHtml(doc)}
`; + html += '
'; + } + return html; + } + + // 列表形态 + const items = Array.isArray(result?.workflows) ? result.workflows : []; + if (items.length === 0) { + return '
当前没有可用工作流。
'; + } + let html = '
'; + html += `
工作流数量:${items.length}
`; + html += '
'; + html += '
'; + 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 += '
'; + html += `
${name}
`; + html += `
${desc}
`; + html += `
来源:${source} | 节点:${nodeCount}
`; + html += '
'; + }); + html += '
'; + return html; +} + +function renderSaveWorkflow(result: any, args: any): string { + const status = formatToolStatusLabel(result, '✓ 已归档'); + let html = '
'; + html += `
状态:${status}
`; + const sourceDir = String(args?.source_dir || ''); + if (sourceDir) { + html += `
源目录:${escapeHtml(sourceDir)}
`; + } + if (result?.workflow_name) { + html += `
工作流:${escapeHtml(String(result.workflow_name))}
`; + } + if (result?.success && typeof result?.node_count === 'number') { + html += `
节点数:${result.node_count}
`; + } + if (result?.success && result?.overwritten) { + html += '
模式:覆盖已有版本
'; + } else if (result?.success && result?.shadows_builtin) { + html += '
模式:用户副本(遮蔽同名内置)
'; + } + html += '
'; + if (!result?.success && result?.error) { + html += `
${escapeHtml(String(result.error))}
`; + } + if (result?.success && result?.summary) { + html += '
'; + html += escapeHtml(String(result.summary)); + html += '
'; + } + return html; +} + function formatPersonalizationFieldLabel(field: string): string { const labelMap: Record = { self_identify: 'AI 自称', diff --git a/static/src/components/chat/quickdock/WorkflowWindow.vue b/static/src/components/chat/quickdock/WorkflowWindow.vue index 068bb225..07da328d 100644 --- a/static/src/components/chat/quickdock/WorkflowWindow.vue +++ b/static/src/components/chat/quickdock/WorkflowWindow.vue @@ -401,6 +401,31 @@ watch( { 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( () => snapshot.value.footnote, diff --git a/static/src/stores/quickDock.ts b/static/src/stores/quickDock.ts index fc6728c0..b7f15e40 100644 --- a/static/src/stores/quickDock.ts +++ b/static/src/stores/quickDock.ts @@ -178,8 +178,14 @@ export const useQuickDockStore = defineStore('quickDock', { bgStore.commands.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 { diff --git a/static/src/stores/workflow.ts b/static/src/stores/workflow.ts index bc88503d..f7b09101 100644 --- a/static/src/stores/workflow.ts +++ b/static/src/stores/workflow.ts @@ -44,6 +44,13 @@ interface WorkflowState { snapshot: WorkflowSnapshot; /** true = 来自任务期实时事件(播动画);false = 来自加载/恢复(静态呈现) */ live: boolean; + /** + * 退出动画进行中:收到 live 的 active=false(deactivate 广播 / slash 退出)时 + * 不立即清空,保留快照供窗口播退出动画,播完由窗口调 finishExit 真正清空。 + * QuickDock 的 hasContent 读取此标志,动画期间保持容器展开(否则容器 300ms + * 收起会吞掉窗口自身的 240ms 退出动画)。 + */ + exiting: boolean; } const emptySnapshot = (): WorkflowSnapshot => ({ @@ -60,7 +67,8 @@ const emptySnapshot = (): WorkflowSnapshot => ({ export const useWorkflowStore = defineStore('workflow', { state: (): WorkflowState => ({ snapshot: emptySnapshot(), - live: false + live: false, + exiting: false }), getters: { isActive(state): boolean { @@ -72,15 +80,40 @@ export const useWorkflowStore = defineStore('workflow', { setWorkflow(snapshot: Partial | null | undefined, live = false) { this.live = live; 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(); return; } + // 新活跃快照到达:中止可能进行中的退出流程(快速重新激活场景) + this.exiting = false; this.snapshot = { ...emptySnapshot(), ...snapshot, active: true }; }, + /** 窗口退出动画播完后由 WorkflowWindow 调用:真正清空状态 */ + finishExit() { + this.exiting = false; + this.live = false; + this.snapshot = emptySnapshot(); + }, /** 对话切换 / 离开对话时清空 */ reset() { this.snapshot = emptySnapshot(); this.live = false; + this.exiting = false; }, /** * 对话切换时的状态回填:拉取目标对话的工作流状态覆盖本地(live=false 静态校正)。 diff --git a/static/src/styles/components/sidebar/_conversation.scss b/static/src/styles/components/sidebar/_conversation.scss index ba054cd2..713415f2 100644 --- a/static/src/styles/components/sidebar/_conversation.scss +++ b/static/src/styles/components/sidebar/_conversation.scss @@ -180,8 +180,8 @@ } .workflow-icon svg { - width: 18px; - height: 18px; + width: 20px; + height: 20px; } .monitor-mode-btn.blocked { diff --git a/static/src/utils/messageVisibility.ts b/static/src/utils/messageVisibility.ts index 623d6cdd..e13cae5d 100644 --- a/static/src/utils/messageVisibility.ts +++ b/static/src/utils/messageVisibility.ts @@ -10,7 +10,10 @@ const COMPACT_FALLBACK_SOURCES = new Set([ 'compression', 'compression_handoff', 'sub_agent', - 'background_command' + 'background_command', + // 工作流运行期通知(inline 注入):紧凑通知;闲时任务入口派发的激活/退出 + // 消息带显式 visibility:'chat',不受本兜底影响 + 'workflow' ]); function normalizeSource(value: any): string { diff --git a/utils/tool_result_formatter/dispatch.py b/utils/tool_result_formatter/dispatch.py index 16654a78..b517eb7a 100644 --- a/utils/tool_result_formatter/dispatch.py +++ b/utils/tool_result_formatter/dispatch.py @@ -52,6 +52,8 @@ from utils.tool_result_formatter.web_media import ( _format_trigger_easter_egg, _format_create_skill, _format_manage_personalization, + _format_list_workflows, + _format_save_workflow, ) from utils.tool_result_formatter.common import _format_failure @@ -133,6 +135,8 @@ TOOL_FORMATTERS = { "todo_update_task": _format_todo_update_task, "update_memory": _format_update_memory, "create_skill": _format_create_skill, + "list_workflows": _format_list_workflows, + "save_workflow": _format_save_workflow, "manage_personalization": _format_manage_personalization, "create_sub_agent": _format_create_sub_agent, "close_sub_agent": _format_close_sub_agent, diff --git a/utils/tool_result_formatter/web_media.py b/utils/tool_result_formatter/web_media.py index e1c855f5..aab1f7c1 100644 --- a/utils/tool_result_formatter/web_media.py +++ b/utils/tool_result_formatter/web_media.py @@ -13,6 +13,17 @@ def _format_create_skill(result_data: Dict[str, Any]) -> str: ] 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: if not result_data.get("success"): return _format_failure("extract_webpage", result_data)