Steer generation rules toward direction pools and readable fills.

Direction-first pools, recipes, and grade manifestation replace enum-tag stuffing; spawned products can be deleted and in-flight runs aborted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 01:02:32 +08:00
co-authored by Cursor
parent 3c751988de
commit e2446b0d97
23 changed files with 2023 additions and 292 deletions
+2 -2
View File
@@ -58,7 +58,7 @@
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。 **存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。 实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段 + **数量**与**一次生成**(单条|同批,同批须钉记录间关系));自评=必要性/属性妥当/格式准确。 生成规则正文:必要性判断 + `rules[]`(设计判断 + 生成与描写含**配方/表现** + **分级表现** + **单层**产物格式 + 可选池:**方向池**以方向概括为主、例子极少,**元素池**仅封闭集合,旁观抽样只抽元素池 + **数量**与**一次生成**);自评=必要性/属性妥当/材料得当/格式准确。
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。 美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。 叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。 回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。
@@ -144,7 +144,7 @@ API:`POST /api/sessions/:id/context-order`(`move` / `set_projection` / `set_
|--------|----------| |--------|----------|
| `context-fragment.v1` 外壳 | brief / 挂载 / **自评十分制(x/10)** / 追问 | | `context-fragment.v1` 外壳 | brief / 挂载 / **自评十分制(x/10)** / 追问 |
| 美学纲领与交互范式 | mosaic(设定逻辑 / 交互范式 / 美学纲领) | | 美学纲领与交互范式 | mosaic(设定逻辑 / 交互范式 / 美学纲领) |
| 生成规则 | 必要性 + 规则卡(方法、单层字段表、池) | | 生成规则 | 必要性 + 规则卡(配方/表现、分级表象、单层字段表、方向池/元素池) |
| 舞台骨架 | 尺度 / 社会 / 状况 / 关键区实体卡 | | 舞台骨架 | 尺度 / 社会 / 状况 / 关键区实体卡 |
| 实现机制 | 支撑点卡 + 关系 + 覆盖检验 | | 实现机制 | 支撑点卡 + 关系 + 覆盖检验 |
| 其它技能正文 | 通用结构化(KV);**应补专用卡** | | 其它技能正文 | 通用结构化(KV);**应补专用卡** |
+2 -2
View File
@@ -29,7 +29,7 @@
| 块 id | 必填 | 用途 | | 块 id | 必填 | 用途 |
|-------|------|------| |-------|------|------|
| `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary | | `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary |
| `opening` | 可选 | **默认问题**正文。开局:配方 `steps` 已预置进「设计.创作流程」,用户首句即坐在 DAG 第一步;若「用户.需求」已有内容,程序**跳过**再抛 opening,直接进本步 LLM | | `opening` | 可选 | **默认问题**正文。点进节点时由程序发给用户(不经本步 LLM),用户先答一轮再产出。整块可省略 = 本步直接 LLM |
| `task` | 是 | 本步任务与验收边界 | | `task` | 是 | 本步任务与验收边界 |
| `principles` | 建议 | 原则 | | `principles` | 建议 | 原则 |
| `probe` | 建议 | 追问策略 | | `probe` | 建议 | 追问策略 |
@@ -173,7 +173,7 @@ recipes/world-simulator|expand-assistant/recipe.yaml
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 | | 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** | | 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** | | 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;宽原则 + 严合同键;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** | | 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` | | 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
| 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 | | 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 |
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) | | 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
@@ -57,8 +57,7 @@ feeds: design_only
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。 用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。 若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。黑板「用户.需求」里已有的句子当作补充材料采纳,不代替本步引导作答。
开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。
``` ```
## principles ## principles
@@ -79,7 +78,7 @@ feeds: design_only
```probe ```probe
本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。 本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。
用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉;开场若已跳过,把「用户.需求」首句当答复。已答清的不重问。 用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉。已答清的不重问。
缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。 缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。
@@ -49,7 +49,7 @@ modules:
repeatable: true repeatable: true
declaration: >- declaration: >-
要精准格式,或本局原创导致知识中无可参考对象、或难以在无指导情况下生成足够可信内容且故事里还要生成时:写出该对象族怎么生成; 要精准格式,或本局原创导致知识中无可参考对象、或难以在无指导情况下生成足够可信内容且故事里还要生成时:写出该对象族怎么生成;
不填最终实例;可按对象反复 创意字段用方向池,不堆枚举词;不填最终实例;可按对象反复
artifact: 设计.生成规则 artifact: 设计.生成规则
params: params:
- key: target - key: target
@@ -19,7 +19,7 @@ when_not: |
规则是 `runtime_only` → 不排,游玩期再生成。 规则是 `runtime_only` → 不排,游玩期再生成。
只要规则不要预写实例 → 不排。 只要规则不要预写实例 → 不排。
boundary: | boundary: |
本技能只做一件事:按指定规则生成 records。规则说怎么写、什么格式、如何用池,就照做。 本技能只做一件事:按指定规则生成 records。规则说怎么写、什么格式、如何用池、填到多厚,就照做。
生成规则:合同制定方——本步不重论证、不修改。 生成规则:合同制定方——本步不重论证、不修改。
feeds: gm feeds: gm
``` ```
@@ -49,8 +49,14 @@ feeds: gm
- `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。 - `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。 - `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。 `batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。 3. 完全按规则的「生成与描写」+「产物格式」+「池」+「分级表现」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。 池的用法以规则 `池型` 为准:
- **方向**:以方向为主。粒度是词条/方向时,字段值可以就是方向名。粒度是句子时,按该方向写开(方向名可作标签,后面仍要达到地板)。方向说明里的专名不是选项库,不要按专名点菜。
- **元素**:从 `条目` 里抽取或必选,写入绑定字段。
有 **配方**:按配方从各组件取方向,拼进目标字段那一格。
有 **分级表现**:实例只填档位短值;不要把整段表象复制进 record,除非 schema 单独设了表象字段。叙事时按规则展开。
无池的描写字段按 `粒度`/`地板`/`表现`/`工作流` 写到够厚。
4. 按规则自检(含地板:句子级不得只用一个词交差);不合格内部重做。已有产物则追加批次(除非用户要求替换)。
5. 输出 JSON;追问通常 `[]`。 5. 输出 JSON;追问通常 `[]`。
summary:`具体实例 · {对象} · {n}条` summary:`具体实例 · {对象} · {n}条`
@@ -59,10 +65,11 @@ summary:`具体实例 · {对象} · {n}条`
## principles ## principles
```principles ```principles
1. 只按规则来:方法、格式、池、数量、一次生成、约束——规则有什么用什么;没有的不发明。 1. 只按规则来:方法、配方、表现、分级表现、格式、池型、数量、一次生成、约束、地板——规则有什么用什么;没有的不发明。
2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。 2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。 3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。方向以方向为主;说明里的专名不是选项。
4. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。 4. 句子级描写必须达到该字段 `地板`;词条/档位/数字可以短。句子级不要只丢一个词交差;方向名可以当选项或标签。
5. 要改合同 → 回「生成规则」,本步不加字段、不改枚举、不改池型。
``` ```
## probe ## probe
@@ -98,7 +105,7 @@ summary:`具体实例 · {对象} · {n}条`
{ {
"名": "合规模", "名": "合规模",
"分数": 8, "分数": 8,
"说明": "是否严格按该规则的方法、单层格式、池与约束生成" "说明": "是否按方法/配方/表现/单层格式/池型(方向为主,元素=抽取)/分级表现/地板生成;句子级是否达到粒度"
} }
], ],
"薄弱点": "一句话;无则空字符串" "薄弱点": "一句话;无则空字符串"
@@ -123,7 +130,7 @@ summary:`具体实例 · {对象} · {n}条`
```checklist ```checklist
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行? - [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组? - [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组?
- [ ] records 单层、键值符合产物格式与全部约束? - [ ] records 单层、键值符合产物格式与全部约束?句子级是否达到地板?有配方是否组装进目标字段?方向是否以方向为主(专名不当选项库)?
- [ ] 自评为合规模?未改合同? - [ ] 自评为合规模?未改合同?
``` ```
@@ -132,6 +139,9 @@ summary:`具体实例 · {对象} · {n}条`
```examples ```examples
好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。 好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。 好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。
好:方向「冷焰」作标签,能力描述按该方向写开到可感知;或粒度=方向时字段值就是「冷焰」。
好:阶层字段填「金丹」,表象留在规则的分级表现里,不把整段对照复制进每条 record。
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。 坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。 坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
坏:句子级只填「阴郁」;或按方向说明里那个专名点菜交差。
``` ```
@@ -2,7 +2,7 @@
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`;范例:`aesthetics-interaction` / `mechanism`。 > 产物外壳:`docs/context-fragment-design.md`;范例:`aesthetics-interaction` / `mechanism`。
> 正文高度自定义(描写文案、schema 字段、池条目随对象族而变);外壳与规则键必须严。 > 正文高度自定义(描写文案、schema 字段、池随对象族而变);外壳与规则键必须严。
## meta ## meta
@@ -11,7 +11,8 @@ name: 生成规则
id: generation-rules id: generation-rules
artifact: 设计.生成规则 artifact: 设计.生成规则
declaration: > declaration: >
要精准格式,或本局原创听了会雾且故事里还要生成时:写出该对象族怎么生成(描写/格式/可选池);不填最终实例;可按对象反复 要精准格式,或本局原创听了会雾且故事里还要生成时:写出该对象族怎么生成(描写/格式/可选池);
创意字段用方向池给创作范围,不堆枚举词;不填最终实例;可按对象反复
when: | when: |
已有「美学纲领与交互范式」或等价整体感觉,且下面两条满足一条就排(可按对象族反复): 已有「美学纲领与交互范式」或等价整体感觉,且下面两条满足一条就排(可按对象族反复):
a. 生成物必须有精准格式。例:武功典籍要有名字、效果、阶层。 a. 生成物必须有精准格式。例:武功典籍要有名字、效果、阶层。
@@ -22,12 +23,12 @@ when_not: |
只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。 只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。
跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「叙事指南与故事推进」。 跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「叙事指南与故事推进」。
boundary: | boundary: |
本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选多池(各绑一个顶层字段)。 本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。
具体实例:按规则填最终 records;本步不写最终实例(池条目≠实例)。 具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。
舞台骨架 / 实现机制:可引用,不重写百科、不重钉既成法则。 舞台骨架 / 实现机制:可引用,不重写百科、不重钉既成法则。
变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。 变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。
随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。 随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。
旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);不依赖整份描写长文。 旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。
feeds: gm,auditor feeds: gm,auditor
``` ```
@@ -43,7 +44,7 @@ feeds: gm,auditor
2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西? 2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西?
3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成? 3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。 4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。
5. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有) 5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。
想到什么写什么;完整规则在正式产物里钉。 想到什么写什么;完整规则在正式产物里钉。
``` ```
@@ -53,11 +54,10 @@ feeds: gm,auditor
```task ```task
你正在执行「生成规则」。产物必须是 **context-fragment.v1** JSON,写入「设计.生成规则」。 你正在执行「生成规则」。产物必须是 **context-fragment.v1** JSON,写入「设计.生成规则」。
本步写出的最终物是**规则**,不是实例。一条规则必须说清两件事,并可附第三件: 本步写出的最终物是**规则**,不是实例。规则是创造这类东西的方法:空白表格 + 怎么填厚 + 必要时给方向。一条规则必须说清两件事,并可附第三件:
1. **生成与描写**:应该怎么写、怎么生成(步骤、依据、语气/切面、约束)。 1. **生成与描写**:按什么顺序写、多方向如何拼进一格、每格填到多厚、分级如何被感知。
2. **产物格式**:生成结果必须遵循的可校验格式(schema)。 2. **产物格式**:生成结果必须遵循的可校验格式(schema)。描写字段要有粒度和地板。
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举; 3. **池**(可选,默认无):优先 **方向池**(方向名 + 一句概括;默认不举例,至多点 1 个专名);**元素池**只给能数完、必须全员一致的封闭集合。无则 `池: []`。
一条规则可多个池;无则 `池: []`。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。 本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
@@ -70,12 +70,19 @@ feeds: gm,auditor
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。 - `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
4. 钉「数量」与「一次生成」(两件不同的事): 4. 钉「数量」与「一次生成」(两件不同的事):
- **数量**:这类对象总共要有几条(fixed / range / contextual)。 - **数量**:这类对象总共要有几条(fixed / range / contextual)。
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`(一次交出并对齐关系);彼此独立 → `单条`(一次只交 1 条,要更多就再点原型)。产物形状始终是 `records[]`,不因单条改成单对象。 - **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`;彼此独立 → `单条`。产物形状始终是 `records[]`。
5. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。 5. 写字段前先做设计判断(写入该 rule 的 `设计判断`):
6. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。 - 列出 **不可穷举项**(姓名、绰号、能力概念、外貌手感等理论上写不完的)→ 这些用方向池或自由写,禁止做成条目枚举。
7. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 - 列出 **封闭集合**(阶层、强度档、有限类型名)→ 这些才用 `allowed_values` 或一个元素池。
- 标签本身看不懂的分级(境界、品阶、强度档、好感档等)→ 必须另写 `分级表现`,把每档写成可感知的详细表象,不要只留字母/阶名。
- 从本对象族真正用得上的面挑 3~6 个正交字段(是什么 / 能做什么 / 和谁关联 / 怎么被感知),不要摊通用名片。
- 外貌、服装、仪式等由几面拼成一格的 → 写 `配方`(多方向组装进同一个顶层字段),不要拆成嵌套对象,也不要为每一面各建元素池。
6. 写满规则:生成与描写(含配方、表现、工作流、反模式)+ 产物格式(**单层 JSON**)+ 池(需要才填)+ 需要时的分级表现。
每个描写类 string 字段必须有 `粒度`(句子)和 `地板`(填到什么才算够)。`示例形状` 必须达到地板,禁止用单标签当示例。
7. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);玩法接口不得漏(如 D&D 怪物的等级、攻击骰)。
8. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。 若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
8. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。 9. 按自评四维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。 若程序已发默认问题:禁止重复开场;在首答上补洞。
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。 summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
@@ -86,15 +93,21 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```principles ```principles
1. 整条规则先问必要性:时机 a(精准格式)或时机 b(原创会雾且还要生成)是否成立?两条都不成立则跳过,勿硬造表。 1. 整条规则先问必要性:时机 a(精准格式)或时机 b(原创会雾且还要生成)是否成立?两条都不成立则跳过,勿硬造表。
2. 一调用一对象族;格式或用途不同则分次编排。 2. 一调用一对象族;格式或用途不同则分次编排。
3. 规则 = 描写/生成方法 + 产物格式;池是可选附属(可多池),不是必填第三设计。 3. 规则 = 描写/生成方法 + 产物格式。池不是必填第三设计;默认 `池: []`。宁少池,勿为每个字段挂一套词表。
4. 属性逐个过筛:每个字段必须对本对象族有意义;禁塞通用名片字段,也禁漏掉玩法接口字段。 4. 属性逐个过筛:每个字段必须对本对象族有意义;禁塞通用名片字段,也禁漏掉玩法接口字段。
5. 产物格式 = **单层 JSON**:一条产物是一层扁平对象;值仅为标量或「标量数组」。 5. 产物格式 = **单层 JSON**:一条产物是一层扁平对象;值仅为标量或「标量数组」。
禁止嵌套 object、禁止 object 数组。此格式要对齐动态表格列、旁观者→主世界层生成、以及投影取字段。 禁止嵌套 object、禁止 object 数组。此格式要对齐动态表格列、旁观者→主世界层生成、以及投影取字段。
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。 类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。
6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。 6. 池分两种,先判断能不能数完:
7. 生命周期先行;未定前不假装可执行「具体实例」。 - **方向池**(默认):选项理论上无限。给 4~8 个方向;每条以**方向名 + 一句概括**为主。默认不举例;需要手感时至多点 1 个专名,禁止把方向写成作品名/例句清单。填写以方向为主:方向名可以当选项或标签;句子级字段按该方向写开。程序不抽方向池。
8. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系(亲属、对立、同场搭档等)→ `同批`;彼此独立 → `单条`。`同批` 至少 2 条,否则改 `单条`。不要把「总共要几个」(数量)和「这一次交几条」(一次生成)混成一件事。 - **元素池**:选项有限且必须全员一致(阶层、武器种类、强度档字母)。条目可穷举;旁观 `need_generate` 只抽这类池。
9. 不写变量更新公式、不写剧情推进、不填最终实例。 姓名、绰号、能力概念、外貌、气质(作描写时)禁止元素池,也禁止用 `allowed_values` 假穷举。同一封闭集合不要既写枚举又再建元素池。
7. 填值要有地板。骰子、是否可破坏可以短。外貌、能力现象、限制、互动手感必须写到可感知。`示例形状` 是地板的样板,不是「阴郁」这种标签。
分级标签本身看不懂时:实例字段仍填短档位;详细对照写在 `分级表现`(每档 = 一句概括 + 一段可感知表象,相邻档必须能分清)。不要只丢「A / 金丹」让人猜。
8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。
9. 生命周期先行;未定前不假装可执行「具体实例」。
10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。
11. 不写变量更新公式、不写剧情推进、不填最终实例。
``` ```
## probe ## probe
@@ -102,9 +115,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```probe ```probe
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。 每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(一次生成:同批 vs 单条);某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。 优先:整条规则是否必要;实例彼此有无必须同场对齐的关系;某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。
若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。 若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。 不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、再给哪个字段加一套枚举池。
``` ```
## output ## output
@@ -113,7 +126,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
{ {
"schema": "context-fragment.v1", "schema": "context-fragment.v1",
"技能": "生成规则", "技能": "生成规则",
"brief": "一句话:对象族 + 建立规则|无需专门规则 + 生命周期(若建立)+ 有池|无池", "brief": "一句话:对象族 + 建立规则|无需专门规则 + 生命周期(若建立)+ 方向池|元素池|无池",
"mount": ["world-simulator", "auditor"], "mount": ["world-simulator", "auditor"],
"稳变": "stable", "稳变": "stable",
"正文": { "正文": {
@@ -149,16 +162,50 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
"模式": "单条|同批", "模式": "单条|同批",
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」" "关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」"
}, },
"设计判断": {
"不可穷举项": ["姓名", "能力概念", "外貌"],
"封闭集合": ["类型", "阶层"],
"正交字段依据": "一句话:为何是这几个字段而不是通用名片"
},
"生成与描写": { "生成与描写": {
"依据": ["可较长;引用上游产物或用户要求"], "依据": ["可较长;引用上游产物或用户要求"],
"方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"], "配方": [
{
"目标字段": "外貌",
"组件": ["轮廓方向", "质感方向"],
"组装": "各取一个方向,拼进外貌这一格;不要拆成嵌套对象"
}
],
"表现": [
{
"针对": "能力现象",
"概括": "写到旁人能看见或听见的变化,以及对手能抓住的空隙",
"例子": ["至多 1 条密度样板;没有就不写"]
}
],
"工作流": ["按配方取方向并组装", "封闭档位对照分级表现", "按地板写描写格"],
"方法": ["描写口径;方向为主(方向名可作选项/标签,句子级按该方向写开);元素池如何抽取"],
"硬约束": ["任何产物不得违反"], "硬约束": ["任何产物不得违反"],
"字段间约束": ["例:阵营=A 时权限≥3"], "字段间约束": ["有就写;无则 []"],
"变化维度": ["允许随机或变化的部分;无则 []"], "变化维度": ["允许随机或变化的部分;无则 []"],
"禁止项": ["不得出现的组合或内容"], "禁止项": ["不得出现的组合或内容"],
"去重规则": ["同批/跨批"], "去重规则": ["同批/跨批"],
"校验": ["类型、枚举、边界、世界与体验一致性;以及是否按方法生成"] "反模式": ["为名字/能力/外貌建枚举条目池", "描写字段只填一个词", "方向池堆作品名或例句当选项", "分级只写阶名没有表象"],
"校验": ["类型、枚举、边界、地板、配方是否组装进目标字段、分级表现是否可分档"]
}, },
"分级表现": [
{
"字段": "阶层",
"说明": "实例只填档位值;叙事按本表展开。无此类分级则 []",
"档位": [
{
"值": "档位名",
"概括": "一句:这档能做到什么量级",
"表象": "可感知的详细对照(看见/听见/碰到什么;须能和上下档分清)"
}
]
}
],
"产物格式": { "产物格式": {
"格式": "单层JSON", "格式": "单层JSON",
"批量时": "array of flat object(每条记录单层)|单条则一个 flat object", "批量时": "array of flat object(每条记录单层)|单条则一个 flat object",
@@ -167,8 +214,11 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
"type": "string|number|integer|boolean|enum|array", "type": "string|number|integer|boolean|enum|array",
"required": true, "required": true,
"description": "语义 + 为何本对象族需要此字段;可较长", "description": "语义 + 为何本对象族需要此字段;可较长",
"粒度": "词条|方向|句子",
"创作": "自由|约束|参考方向|必选元素",
"地板": "描写类必填:填到什么才算够;词条/数字可省略",
"allowed_values": [ "allowed_values": [
{ "value": "枚举值", "meaning": "含义" } { "value": "仅封闭集合", "meaning": "含义" }
], ],
"minimum": 0, "minimum": 0,
"maximum": 100, "maximum": 100,
@@ -178,28 +228,32 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
} }
}, },
"示例形状": { "示例形状": {
"名称": "……", "名称": "达到地板的名字,不是「某某」",
"等级": 3, "阶层": "金丹",
"攻击骰": "2d6+2", "外貌": "按配方拼好的一段可感知外貌,不是「黑发高个」",
"气质": "阴郁" "能力描述": "写清现象与可被抓住的空隙;禁止一个词交差"
} }
}, },
"池": [ "池": [
{ {
"pool_id": "英文 kebab-case;同规则内唯一", "pool_id": "英文 kebab-case;同规则内唯一",
"名称": "人话名", "名称": "人话名",
"绑定字段": "单层 schema 的顶层字段名,例:气质 / 攻击花招", "池型": "方向|元素",
"用途": "灵感组装|随机抽取|枚举生成|其它", "绑定字段": "单层 schema 的顶层字段名",
"说明": "该字段如何用本池;与「生成与描写.方法」如何配合;可较长", "用途": "方向:方向为主的创作范围|元素:随机抽取或必选",
"说明": "该字段如何用本池;多池绑同一字段时看配方",
"方向": {
"方向名": "一句概括。默认到此为止;至多再点 1 个专名。仅池型=方向时使用"
},
"条目": [ "条目": [
{ {
"id": "可选稳定 id", "id": "可选稳定 id",
"内容": "材料正文或短标签;结构随池自定义,但同池宜一致", "内容": "仅池型=元素:可穷举的标签或短词",
"权重": 1, "权重": 1,
"标签": ["可选分类标签"] "标签": ["可选分类标签"]
} }
], ],
"抽取或组装规则": "可较长;无特殊则写「均匀随机」或「按方法写入绑定字段」" "抽取或组装规则": "方向池写「以方向为主;句子级按该方向写开」;多池同字段看配方;元素池写抽取方式,默认均匀随机"
} }
] ]
} }
@@ -216,12 +270,17 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
{ {
"名": "属性妥当", "名": "属性妥当",
"分数": 8, "分数": 8,
"说明": "逐字段:有无冗余(如女主规则的性别)或缺失(如 D&D 怪物缺等级/攻击骰);跳过时写 N/A" "说明": "逐字段:有无冗余或缺失玩法接口;跳过时写 N/A"
},
{
"名": "材料得当",
"分数": 8,
"说明": "不可穷举项是否做成方向池/自由而非假穷举;方向是否以概括为主、例子极少;元素池是否仅封闭集合;该细的分级是否有表象;描写字段地板是否够密;跳过时写 N/A"
}, },
{ {
"名": "格式准确", "名": "格式准确",
"分数": 8, "分数": 8,
"说明": "是否严格单层 JSON、类型/必填/枚举/边界可对接动态表格·旁观者生成·投影;跳过时写 N/A" "说明": "是否严格单层 JSON、类型/必填可对接动态表格·旁观者生成·投影;跳过时写 N/A"
} }
], ],
"薄弱点": "一句话" "薄弱点": "一句话"
@@ -242,22 +301,26 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
硬规则(公共三段 + 本技正文): 硬规则(公共三段 + 本技正文):
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。 2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/材料得当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。 3. **正文键固定**:本步参数、必要性判断、rules、增量说明。
4. **每条 rule 必含**:生成与描写、产物格式、数量、一次生成;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。 4. **每条 rule 必含**:设计判断、生成与描写、产物格式、数量、一次生成;**池可空数组**。`生成与描写.配方` / `表现` 无则 `[]`。标签看不懂的分级必须有 `分级表现`(每档概括+表象,相邻可分清);无此类则 `分级表现: []`。有池时 `绑定字段` 必须是单层 schema 的顶层键,且必须标 `池型`。多个方向池绑同一字段时配方必须写清怎么拼进该格。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。 5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。描写类 string 字段必须有 `粒度` 与 `地板`;`示例形状` 必须达到地板。
6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。 6. **池型**:`方向` 必须有 `方向`(对象:方向名→一句概括,4~8 条;默认不举例,至多 1 个专名),`条目` 可 `[]` 或省略。`元素` 必须有可穷举 `条目`,不要写 `方向`。不可穷举项禁止 `池型=元素`,也禁止用 `allowed_values` 冒充。
7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。 7. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
8. `mount` 默认主世界层 + 旁观维护(旁观只需合同键:rule_id/格式/必要性;描写长文可在投影时 summary);**禁止**写 `order`。summary 见 task。 8. 池≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
9. `mount` 默认主世界层 + 旁观维护(旁观只需合同键:rule_id/格式/必要性;描写长文可在投影时 summary);**禁止**写 `order`。summary 见 task。
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 含 schema / brief / 正文 / 自评 / 追问?
- [ ] 自评为必要性 / 属性妥当 / 格式准确? - [ ] 自评为必要性 / 属性妥当 / 材料得当 / 格式准确?
- [ ] 整条规则必要性有举证?不成立时 rules=[]? - [ ] 整条规则必要性有举证?不成立时 rules=[]?
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?数量与一次生成已分清(总共几个 vs 这一次交几条)?同批有关系、单条写彼此独立? - [ ] 建立时:有设计判断(不可穷举 vs 封闭集合)?逐字段无冗余无漏接口?产物为单层 JSON?数量与一次生成已分清?
- [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表? - [ ] 描写字段有粒度+地板,示例形状不是单标签?
- [ ] 该细的分级有概括+可感知表象,不是只留阶名?
- [ ] 多方向拼一格时有配方?表现是概括+极少例子?
- [ ] 池:无则 [];有则标池型;方向池以概括为主、例子极少;元素池仅封闭集合;未把名字/能力做成枚举词表?
- [ ] 未夹带最终实例、变量公式、其它对象族、百科? - [ ] 未夹带最终实例、变量公式、其它对象族、百科?
``` ```
@@ -265,19 +328,21 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```examples ```examples
好: 好:
- 武功典籍:单层含 名字/效果/阶层(时机 a)。 - 武功典籍:单层含 名字/效果/阶层。阶层短填 + `分级表现` 把每阶写成可感知对照;名字与效果用方向池(方向名+一句概括)。
- 「心之彩」:本局原创、听了会雾,故事里还要随机生成(时机 b)。 - 外貌:轮廓方向 × 质感方向,配方拼进 `外貌` 一格,不要 `{发色:{}}`。
- D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。 - 「心之彩」:颜色/性格用方向池(如「冷焰:静、刺、余烬不散」),不要列出 20 种色号,也不要堆作品名。
- 女主规则:不设「性别」字段;关系切面进表;池可空。 - D&D 怪物:名称/等级/攻击骰齐全;等级若只写数字看不懂,补分级表现;气质用方向池+句子地板。
- 互为仇敌的两名开局 NPC:一次生成=同批,关系写清对立如何对齐;数量=2。 - 女主规则:不设「性别」;池可空;内在冲突自由写并给地板。
- 可互换的酒馆路人:一次生成=单条,关系写「彼此独立」;要 5 个就点 5 次。 - 互为仇敌的两名开局 NPC:一次生成=同批;可互换的酒馆路人:一次生成=单条。
- 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。 - 现实名车、西幻常见种族 → 无需专门规则。
- 现实名车、西幻常见种族 → 无需专门规则(常识能推且不需精准格式)。
坏: 坏:
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。 - 为气质/花招/绰号/能力各建一个 8~20 词的元素池,填出来全是标签。
- 方向池每条挂 3 个作品名或例句,变成假选项库。
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层。
- `示例形状` 里 `能力描述: "时空斩"` 或 `气质: "阴郁"` —— 地板被示例教坏。
- 阶层/强度只有「A」「金丹」,没有表象,叙事时没法写。
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。 - 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
- 有亲属关系的三人却标成单条,分三次各写一条,关系对不上。 - 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。
- 把「总共要 5 个」写成一次生成=同批,但彼此并无必须同场的关系。
- 把写好的 NPC 整份塞进池当最终实例。 - 把写好的 NPC 整份塞进池当最终实例。
``` ```
@@ -32,4 +32,5 @@ prompt_excerpt: |
多数轮保持空:need_generate=false、table_ops=[]、notes=[]。 多数轮保持空:need_generate=false、table_ops=[]、notes=[]。
table_ops 仅在确需改「变量.当前」等表字段时使用(op=set|delta)。 table_ops 仅在确需改「变量.当前」等表字段时使用(op=set|delta)。
need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。 need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。
程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。
禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。 禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。
+2 -2
View File
@@ -88,12 +88,12 @@ export class Blackboard {
this.writeSeq += 1; this.writeSeq += 1;
const existing = this.getLatestByTag(input.tag); const existing = this.getLatestByTag(input.tag);
const item: BlackboardItem = { const item: BlackboardItem = {
id: randomUUID(), id: existing?.id ?? randomUUID(),
tag: input.tag, tag: input.tag,
content: input.content, content: input.content,
source: input.source, source: input.source,
scope: input.scope ?? existing?.scope, scope: input.scope ?? existing?.scope,
createdAt: now, createdAt: existing?.createdAt ?? now,
updatedAt: `${now}#${this.writeSeq}`, updatedAt: `${now}#${this.writeSeq}`,
dependencies: input.dependencies, dependencies: input.dependencies,
metadata: input.metadata, metadata: input.metadata,
+2 -1
View File
@@ -421,7 +421,8 @@ export function canApplyEvent(
reason?.kind === "approve_step" || reason?.kind === "approve_step" ||
reason?.kind === "worker_questions" || reason?.kind === "worker_questions" ||
reason?.kind === "input" || reason?.kind === "input" ||
reason?.kind === "revision" reason?.kind === "revision" ||
reason?.kind === "review_artifact"
); );
case "creation_step_pick_awaited": case "creation_step_pick_awaited":
return session.phase === "running"; return session.phase === "running";
+346 -71
View File
@@ -55,7 +55,10 @@ import {
extractUnitContentFromDraft, extractUnitContentFromDraft,
isDesignDiskWorker, isDesignDiskWorker,
isDesignUnitArtifact, isDesignUnitArtifact,
normalizeCreationUnitId,
parseAcceptedContentStore,
parseAcceptedUnits, parseAcceptedUnits,
dropAcceptedUnitContent,
upsertAcceptedUnitContent, upsertAcceptedUnitContent,
} from "../skills/creation-units.js"; } from "../skills/creation-units.js";
import { import {
@@ -80,6 +83,8 @@ import {
spawnRepeatableCreationStep, spawnRepeatableCreationStep,
spawnInstanceFromPrototype, spawnInstanceFromPrototype,
removeUnstartedInstance, removeUnstartedInstance,
removeRepeatableInstance,
pruneRepeatableArtifactContent,
isPrototypeStep, isPrototypeStep,
isPrototypeSelectable, isPrototypeSelectable,
isInstanceStep, isInstanceStep,
@@ -93,7 +98,6 @@ import {
patchCreationFlowStepParams, patchCreationFlowStepParams,
pickRecordedStepId, pickRecordedStepId,
resolveDesignStepBinding, resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag, isProgressPointerTag,
missingRequiredStepParams, missingRequiredStepParams,
stepUnitId, stepUnitId,
@@ -101,6 +105,7 @@ import {
stringifyModuleOpeningState, stringifyModuleOpeningState,
finalizeCreationFlow, finalizeCreationFlow,
type CreationFlow, type CreationFlow,
type CreationFlowStep,
type CreationFlowStepParams, type CreationFlowStepParams,
type ModuleCatalog, type ModuleCatalog,
type ModuleCatalogEntry, type ModuleCatalogEntry,
@@ -185,6 +190,7 @@ import type {
AcceptanceMode, AcceptanceMode,
ActiveSkillSnapshot, ActiveSkillSnapshot,
ApplyEventResult, ApplyEventResult,
ArtifactRecord,
MainAgentDecision, MainAgentDecision,
PhaseEffect, PhaseEffect,
RuntimeEvent, RuntimeEvent,
@@ -339,6 +345,71 @@ export class PhaseRuntime {
this.lastWorkerRunSnapshot = null; this.lastWorkerRunSnapshot = null;
} }
/**
* 提前停止当前生成:还原开跑快照,回到生成前的等待面(或分层图)。
* 不重跑。
*/
abortCurrentRun(): RuntimeSession {
const snap = this.lastWorkerRunSnapshot;
if (snap) {
this.restoreFromCheckpoint(snap.runtimeSession, snap.blackboardItems);
}
return this.settleAfterAbort();
}
/** 停止后落到可操作等待态,避免停在 running 空转。 */
private settleAfterAbort(): RuntimeSession {
const s = this.session;
if (s.waitingReason) return s;
const pendingId = s.pendingArtifactId?.trim();
const revisionTarget =
typeof s.slots.revisionTargetArtifactId === "string"
? s.slots.revisionTargetArtifactId.trim()
: "";
const reviewId =
pendingId && s.artifacts.some((a) => a.id === pendingId)
? pendingId
: revisionTarget && s.artifacts.some((a) => a.id === revisionTarget)
? revisionTarget
: "";
if (reviewId) {
this.session = {
...s,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: reviewId },
pendingArtifactId: reviewId,
currentWorkerId: undefined,
pendingDecision: undefined,
updatedAt: new Date().toISOString(),
};
return this.session;
}
if (this.hasCreationFlow()) {
this.clearProposedStep();
this.session = {
...s,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
pendingDecision: undefined,
currentWorkerId: undefined,
updatedAt: new Date().toISOString(),
};
return this.session;
}
if (this.recoverOrphanedRun()) return this.session;
if (s.phase === "running") {
this.session = {
...s,
phase: "waiting_user",
waitingReason: { kind: "input", message: "已停止生成,可继续说明。" },
currentWorkerId: undefined,
pendingDecision: undefined,
updatedAt: new Date().toISOString(),
};
}
return this.session;
}
/** /**
* 执行中卡住:有进行中的 worker 则按开跑快照重跑;否则重跑总管。 * 执行中卡住:有进行中的 worker 则按开跑快照重跑;否则重跑总管。
*/ */
@@ -711,12 +782,19 @@ export class PhaseRuntime {
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。 * 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
*/ */
async leaveCreationStep(): Promise<RuntimeSession> { async leaveCreationStep(): Promise<RuntimeSession> {
if (this.session.phase === "running" && !this.session.waitingReason) {
this.abortCurrentRun();
}
if (this.session.waitingReason?.kind === "pick_creation_step") {
return this.session;
}
const waiting = this.session.waitingReason?.kind; const waiting = this.session.waitingReason?.kind;
const allowed = const allowed =
waiting === "worker_questions" || waiting === "worker_questions" ||
waiting === "input" || waiting === "input" ||
waiting === "revision" || waiting === "revision" ||
waiting === "approve_step"; waiting === "approve_step" ||
waiting === "review_artifact";
if (!allowed) { if (!allowed) {
throw new Error("当前不能返回节点选择"); throw new Error("当前不能返回节点选择");
} }
@@ -724,6 +802,7 @@ export class PhaseRuntime {
throw new Error("游玩中不能返回创作节点"); throw new Error("游玩中不能返回创作节点");
} }
const stepId = this.readPinnedStepId(); const stepId = this.readPinnedStepId();
this.forgetUnansweredModuleOpening(stepId);
const removed = this.unspawnCurrentInstanceIfEmpty(); const removed = this.unspawnCurrentInstanceIfEmpty();
this.clearProposedStep(); this.clearProposedStep();
this.clearCurrentStepPointers(); this.clearCurrentStepPointers();
@@ -768,6 +847,42 @@ export class PhaseRuntime {
return { id: step.id, name: step.name }; return { id: step.id, name: step.name };
} }
/** 离开节点时若默认问题只展示过、还没答:清状态,下次点进再出引导。 */
private forgetUnansweredModuleOpening(stepId: string): void {
const id = stepId.trim();
if (!id) return;
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const step = flow ? findStepByRef(flow, id) : null;
const key = step ? stepUnitId(step) : id;
const state = parseModuleOpeningState(
this.session.slots[SLOT_CREATION_MODULE_OPENING_STATE] ??
this.blackboard.getContentByTag(CREATION_MODULE_OPENING_STATE_TAG),
);
if (state[key] !== "shown") return;
delete state[key];
const raw = stringifyModuleOpeningState(state);
this.blackboard.write({
tag: CREATION_MODULE_OPENING_STATE_TAG,
content: raw,
source: "runtime",
});
this.blackboard.write({
tag: CREATION_MODULE_OPENING_TAG,
content: "",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_MODULE_OPENING_STATE]: raw,
[CREATION_MODULE_OPENING_TAG]: undefined,
},
};
}
private clearCurrentStepPointers(): void { private clearCurrentStepPointers(): void {
this.blackboard.write({ this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG, tag: CREATION_CURRENT_STEP_TAG,
@@ -856,6 +971,85 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/** 图上删除可增殖产物(已验收也可)。 */
async deleteCreationStep(stepId: string): Promise<RuntimeSession> {
const id = stepId.trim();
if (!id) throw new Error("请指定要删除的产物");
this.syncSlotsToBlackboard(this.session);
const catalog = await this.loadActiveModuleCatalog();
const flow = this.readNormalizedFlow(catalog);
if (!flow) throw new Error("还没有工作流计划");
const result = removeRepeatableInstance({ flow, stepId: id, catalog });
if ("error" in result) throw new Error(result.error);
this.writeCreationFlow(result.flow);
this.forgetDeletedCreationStep(result.removed, result.flow, catalog);
this.onMessage(`已删除「${result.removed.name}」产物 ${result.removed.id}。`);
debugLog("step", `删除产物 ${result.removed.id}`);
return this.session;
}
private forgetDeletedCreationStep(
step: CreationFlowStep,
flow: CreationFlow,
catalog: ModuleCatalog | null,
): void {
const unitId = step.id;
const accepted = this.readAcceptedStepIds().filter((item) => item !== unitId);
const acceptedRaw = JSON.stringify(accepted);
this.blackboard.write({
tag: CREATION_ACCEPTED_UNITS_TAG,
content: acceptedRaw,
source: "runtime",
});
const contentRaw = dropAcceptedUnitContent(
this.blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
unitId,
);
this.blackboard.write({
tag: CREATION_ACCEPTED_CONTENT_TAG,
content: contentRaw,
source: "runtime",
});
const now = new Date().toISOString();
const artifacts = this.session.artifacts.map((a) =>
a.stepId === unitId && a.status !== "rejected"
? { ...a, status: "rejected" as const, updatedAt: now }
: a,
);
const remainingSameModule = accepted.filter((item) => {
const other = findStepByRef(flow, item);
return other?.name === step.name;
}).length;
const tag = findModuleByName(catalog, step.name)?.artifact?.trim();
if (tag) {
const pruned = pruneRepeatableArtifactContent({
raw: this.blackboard.getContentByTag(tag) ?? "",
step,
remainingSameModule,
});
this.blackboard.write({
tag,
content: pruned,
source: "runtime",
});
}
const pinned = this.readPinnedStepId();
this.session = {
...this.session,
artifacts,
currentStepId: pinned === unitId ? undefined : this.session.currentStepId,
slots: {
...this.session.slots,
[SLOT_CREATION_ACCEPTED_UNITS]: accepted,
[CREATION_ACCEPTED_UNITS_TAG]: acceptedRaw,
[CREATION_ACCEPTED_CONTENT_TAG]: contentRaw,
...(tag ? { [tag]: this.blackboard.getContentByTag(tag) ?? "" } : {}),
},
updatedAt: now,
};
if (pinned === unitId) this.clearCurrentStepPointers();
}
private writeCreationFlow(flow: CreationFlow): void { private writeCreationFlow(flow: CreationFlow): void {
const content = stringifyCreationFlow(flow); const content = stringifyCreationFlow(flow);
this.blackboard.write({ this.blackboard.write({
@@ -1164,7 +1358,7 @@ export class PhaseRuntime {
case "run_worker": case "run_worker":
if ( if (
effect.workerId === DESIGN_STEP_WORKER_ID && effect.workerId === DESIGN_STEP_WORKER_ID &&
!this.hasPinnedReadyStep() !this.canRunPinnedDesignStep()
) { ) {
await this.awaitCreationStepPick(); await this.awaitCreationStepPick();
break; break;
@@ -1273,6 +1467,29 @@ export class PhaseRuntime {
return isStepReady(flow, step, this.readAcceptedStepIds()); return isStepReady(flow, step, this.readAcceptedStepIds());
} }
/** 已验收节点回头改:isStepReady 为 false,但仍须跑 worker,不能弹回分层图。 */
private canRunPinnedDesignStep(): boolean {
if (this.hasPinnedReadyStep()) return true;
if (typeof this.session.slots.revisionTargetArtifactId === "string") {
return Boolean(this.session.slots.revisionTargetArtifactId.trim());
}
if (
isInheritExistingFlag(
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
)
) {
return true;
}
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const step = flow
? findStepByRef(flow, this.readPinnedStepId())
: null;
return Boolean(step && isReviseStep(step));
}
private pinCreationStepSnapshot(params: { private pinCreationStepSnapshot(params: {
stepId: string; stepId: string;
name: string; name: string;
@@ -1371,15 +1588,24 @@ export class PhaseRuntime {
}; };
flow = spawned.flow; flow = spawned.flow;
step = spawned.step; step = spawned.step;
} else if (reenter) { } else if (!reenter && !isStepReady(flow, step, accepted)) {
if (!isStepAccepted(step, accepted)) {
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
}
} else if (!isStepReady(flow, step, accepted)) {
throw new Error(`「${step.name}」的前置节点还没完成,不能开始`); throw new Error(`「${step.name}」的前置节点还没完成,不能开始`);
} }
const pendingModule = findModuleByName(catalog, step.name); const pendingModule = findModuleByName(catalog, step.name);
if (reenter) {
const artTag = pendingModule?.artifact?.trim();
const hasArt = Boolean(
artTag && this.blackboard.getContentByTag(artTag)?.trim(),
);
if (
!isStepAccepted(step, accepted) &&
!hasArt &&
!this.findReenterArtifact(step.id, artTag)
) {
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
}
}
const paramSpecs = pendingModule?.params ?? []; const paramSpecs = pendingModule?.params ?? [];
const auto = pendingModule?.auto === true; const auto = pendingModule?.auto === true;
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) { if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
@@ -1417,6 +1643,21 @@ export class PhaseRuntime {
const waitingPick = const waitingPick =
this.session.waitingReason?.kind === "pick_creation_step"; this.session.waitingReason?.kind === "pick_creation_step";
const needsParamForm = paramsMissing.length > 0 && !auto; const needsParamForm = paramsMissing.length > 0 && !auto;
if (reenter) {
const existing = this.ensureReenterArtifact(
pinned.id,
pendingModule?.artifact,
);
if (existing) {
this.reopenArtifactForRevision(existing, pinned);
return;
}
throw new Error(
`找不到「${pinned.name}」的已有产物,无法回头修改。要新开一条请点原型上的「增殖」。`,
);
}
if (needsParamForm) { if (needsParamForm) {
const decision = createDecision({ const decision = createDecision({
action: "run_worker", action: "run_worker",
@@ -1453,6 +1694,101 @@ export class PhaseRuntime {
}); });
} }
/** 回头修改:打开该步已有产物,让用户像未验收时一样写意见再改。 */
private findReenterArtifact(
stepId: string,
artifactTag?: string,
): ArtifactRecord | undefined {
const id = stepId.trim();
const tag = artifactTag?.trim();
if (!id && !tag) return undefined;
const usable = this.session.artifacts.filter(
(a) => a.workerId === DESIGN_STEP_WORKER_ID && a.status !== "rejected",
);
const byStep = id ? usable.filter((a) => a.stepId === id) : [];
const byTag = tag
? usable.filter((a) => a.outputTags.includes(tag))
: [];
const matches = byStep.length ? byStep : byTag;
const accepted = matches.filter((a) => a.status === "accepted");
const pool = accepted.length ? accepted : matches;
return pool.at(-1);
}
private ensureReenterArtifact(
stepId: string,
artifactTag?: string,
): ArtifactRecord | undefined {
const found = this.findReenterArtifact(stepId, artifactTag);
if (found) return found;
const tag = artifactTag?.trim();
let content = tag
? this.blackboard.getContentByTag(tag)?.trim() || ""
: "";
if (!content) {
content = this.readAcceptedUnitSlice(stepId);
if (content && tag) {
this.blackboard.write({
tag,
content,
source: "runtime",
});
}
}
if (!tag || !content) return undefined;
const artifact = createArtifact({
workerId: DESIGN_STEP_WORKER_ID,
stepId,
outputTags: [tag],
summary: "既有产物",
});
this.session = {
...this.session,
artifacts: [...this.session.artifacts, artifact],
};
return artifact;
}
private readAcceptedUnitSlice(stepId: string): string {
const store = parseAcceptedContentStore(
this.blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
);
const entry = store.units[normalizeCreationUnitId(stepId)];
if (!entry || entry.content == null) return "";
return typeof entry.content === "string"
? entry.content.trim()
: JSON.stringify(entry.content, null, 2).trim();
}
private reopenArtifactForRevision(
artifact: ArtifactRecord,
step: { id: string; name: string },
): void {
const now = new Date().toISOString();
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
pendingArtifactId: artifact.id,
currentWorkerId: DESIGN_STEP_WORKER_ID,
currentStepId: step.id,
pendingDecision: undefined,
artifacts: this.session.artifacts.map((a) =>
a.id === artifact.id
? { ...a, status: "under_review", updatedAt: now }
: a,
),
slots: {
...this.session.slots,
revisionTargetArtifactId: artifact.id,
},
updatedAt: now,
};
this.onMessage(
`已回到「${step.name}」,可按意见修改现有产物(继承原文),或返回节点再选别的步骤。`,
);
}
/** /**
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。 * 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
*/ */
@@ -2204,7 +2540,7 @@ export class PhaseRuntime {
}); });
if (!result) { if (!result) {
this.onMessage( this.onMessage(
`[旁观维护] need_generate=${need.rule_id}:未找到可抽样的池(请检查设计.生成规则)`, `[旁观维护] need_generate=${need.rule_id}:无可程序抽样的元素池(方向池由填写方按规则自创)`,
); );
return; return;
} }
@@ -2350,48 +2686,6 @@ export class PhaseRuntime {
if (phase === "answered") return false; if (phase === "answered") return false;
// 配方开局模块 + 用户首句已在「用户.需求」→ 默认视为 opening 已做完,直接进 LLM
const demand = (
this.blackboard.getContentByTag("用户.需求") ??
this.session.slots["用户.需求"] ??
""
)
.toString()
.trim();
if (
shouldSkipModuleOpening({
demand,
dependsOn: binding.step.depends_on,
phase,
})
) {
state[stepKey] = "answered";
const raw = stringifyModuleOpeningState(state);
this.blackboard.write({
tag: CREATION_MODULE_OPENING_STATE_TAG,
content: raw,
source: "runtime",
});
this.blackboard.write({
tag: CREATION_MODULE_OPENING_TAG,
content: "(已跳过默认问题:用户首句见「用户.需求」)",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_MODULE_OPENING_STATE]: raw,
[CREATION_MODULE_OPENING_TAG]:
"(已跳过默认问题:用户首句见「用户.需求」)",
},
};
this.onMessage(
`[系统] ${binding.module.name} · 已坐在配方开局步,首句见「用户.需求」,跳过默认问题`,
);
return false;
}
if (phase === "shown" && this.session.resumeContext) { if (phase === "shown" && this.session.resumeContext) {
// 用户刚答完默认问题 → 标记已答,继续走 LLM // 用户刚答完默认问题 → 标记已答,继续走 LLM
state[stepKey] = "answered"; state[stepKey] = "answered";
@@ -2412,26 +2706,7 @@ export class PhaseRuntime {
return false; return false;
} }
if (phase === "shown" && !this.session.resumeContext) { // 首次,或开场已发出但用户还没答(离开节点 / 恢复丢了 resume):展示引导,等用户先说一轮
// 异常重入:开场已发过但未走 resume;避免死循环,直接进 LLM
state[stepKey] = "answered";
const raw = stringifyModuleOpeningState(state);
this.blackboard.write({
tag: CREATION_MODULE_OPENING_STATE_TAG,
content: raw,
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_MODULE_OPENING_STATE]: raw,
},
};
return false;
}
// 首次:发出默认问题
state[stepKey] = "shown"; state[stepKey] = "shown";
const raw = stringifyModuleOpeningState(state); const raw = stringifyModuleOpeningState(state);
this.blackboard.write({ this.blackboard.write({
+58 -1
View File
@@ -1410,6 +1410,31 @@ export class SessionManager {
return this.toView(id); return this.toView(id);
} }
/** 中止当前生成,不重跑;回到生成前的等待面或分层图。 */
async abortRun(id: string): Promise<SessionView> {
const s = this.require(id);
const session = s.runtime.getSession();
if (session.phase !== "running" || session.waitingReason) {
return this.toView(id);
}
const cutoff = s.runMessageCutoff ?? s.messages.length;
s.messages = s.messages.slice(0, cutoff);
try {
const owned = await this.runExclusive(id, s, async () => {
s.runtime.abortCurrentRun();
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止生成失败", detail);
await this.recoverFailedRun(s, detail);
}
s.messages.push(this.msg("system", "已停止生成。"));
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 删除消息及其后的对话 */ /** 删除消息及其后的对话 */
async deleteMessage(id: string, messageId: string): Promise<SessionView> { async deleteMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id); const s = this.require(id);
@@ -1686,6 +1711,27 @@ export class SessionManager {
return this.toView(id); return this.toView(id);
} }
async deleteStep(id: string, stepId: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是删除产物的时机");
}
await s.runtime.deleteCreationStep(stepId);
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 删除产物失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */ /** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
private canOfferLeaveCreationStep( private canOfferLeaveCreationStep(
s: ManagedSession, s: ManagedSession,
@@ -1699,10 +1745,21 @@ export class SessionManager {
return false; return false;
} }
const kind = reason?.kind; const kind = reason?.kind;
if (session.phase === "running" && !kind) {
return Boolean(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG)?.trim(),
);
}
if (kind === "review_artifact") {
return Boolean(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG)?.trim(),
);
}
if ( if (
kind !== "worker_questions" && kind !== "worker_questions" &&
kind !== "input" && kind !== "input" &&
kind !== "revision" kind !== "revision" &&
kind !== "approve_step"
) { ) {
return false; return false;
} }
+9
View File
@@ -515,6 +515,12 @@ const server = createServer(async (req, res) => {
typeof body.moduleName === "string" ? body.moduleName : "", typeof body.moduleName === "string" ? body.moduleName : "",
); );
break; break;
case "delete_step":
view = await sessionManager.deleteStep(
sessionId,
typeof body.stepId === "string" ? body.stepId : "",
);
break;
case "run_outline": case "run_outline":
view = await sessionManager.runOutline(sessionId); view = await sessionManager.runOutline(sessionId);
break; break;
@@ -524,6 +530,9 @@ const server = createServer(async (req, res) => {
case "retry_run": case "retry_run":
view = await sessionManager.abortAndRetry(sessionId); view = await sessionManager.abortAndRetry(sessionId);
break; break;
case "abort_run":
view = await sessionManager.abortRun(sessionId);
break;
default: default:
json(res, 400, { error: "未知 action" }); json(res, 400, { error: "未知 action" });
return; return;
+95 -17
View File
@@ -1224,21 +1224,6 @@ export function creationFlowFromRecipeSeed(
}; };
} }
/**
* 已坐在配方开局步(无 depends_on)且「用户.需求」有首句时:
* 跳过程序再抛模块 opening——首句即该步对话,不是「先开场再进 DAG」。
*/
export function shouldSkipModuleOpening(opts: {
demand: string;
dependsOn?: string[] | null;
phase?: "shown" | "answered" | null;
}): boolean {
if (opts.phase === "shown" || opts.phase === "answered") return false;
if (!String(opts.demand ?? "").trim()) return false;
const deps = opts.dependsOn ?? [];
return deps.length === 0;
}
/** 无 id 时的兜底(目录仍应显式写 id) */ /** 无 id 时的兜底(目录仍应显式写 id) */
function slugFromName(name: string): string { function slugFromName(name: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -2623,17 +2608,110 @@ export function removeUnstartedInstance(
if (!step || !isInstanceStep(step) || isStepAccepted(step, acceptedStepIds)) { if (!step || !isInstanceStep(step) || isStepAccepted(step, acceptedStepIds)) {
return flow; return flow;
} }
return dropInstanceFromFlow(flow, step.id);
}
function dropInstanceFromFlow(flow: CreationFlow, stepId: string): CreationFlow {
return { return {
...flow, ...flow,
steps: flow.steps steps: flow.steps
.filter((s) => s.id !== step.id) .filter((s) => s.id !== stepId)
.map((s) => ({ .map((s) => ({
...s, ...s,
depends_on: s.depends_on.filter((d) => d !== step.id), depends_on: s.depends_on.filter((d) => d !== stepId),
})), })),
}; };
} }
function repeatableInstanceLabel(step: CreationFlowStep): string {
const target = titleFromStepParams(step.params);
if (target && target !== step.name) return `${step.name} · ${target}`;
return step.id !== step.name ? `${step.name}(${step.id})` : step.name;
}
/**
* 图上删除可增殖产物:已验收也可删。
* 另有实例依赖这条时拒绝;收口等一次性节点只解开依赖。
*/
export function removeRepeatableInstance(params: {
flow: CreationFlow;
stepId: string;
catalog?: ModuleCatalog | null;
}): { flow: CreationFlow; removed: CreationFlowStep } | { error: string } {
const step = findStepByRef(params.flow, params.stepId.trim());
if (!step) return { error: "找不到节点" };
if (!isInstanceStep(step)) {
return { error: "只能删除可增殖能力长出来的产物" };
}
const mod = findModuleByName(params.catalog, step.name);
if (mod && mod.repeatable !== true) {
return { error: `「${step.name}」不是可增殖能力,不能从图上删` };
}
if (!mod && !step.from) {
return { error: "只能删除可增殖能力长出来的产物" };
}
const blockers = params.flow.steps.filter(
(s) =>
s.id !== step.id &&
isInstanceStep(s) &&
s.depends_on.includes(step.id),
);
if (blockers.length) {
return {
error: `还有产物依赖这条:${blockers.map(repeatableInstanceLabel).join("、")}。请先删那些产物。`,
};
}
return { flow: dropInstanceFromFlow(params.flow, step.id), removed: step };
}
/** 共享产物 tag 里按 rule_id 抽掉已删实例;同能力已无实例则清空。 */
export function pruneRepeatableArtifactContent(params: {
raw: string;
step: CreationFlowStep;
remainingSameModule: number;
}): string {
if (params.remainingSameModule <= 0) return "";
const stripped = stripFragmentRowsMatchingStep(params.raw, params.step);
return stripped ?? params.raw;
}
function stripFragmentRowsMatchingStep(
raw: string,
step: CreationFlowStep,
): string | null {
const parsed = tryParseJsonDoc(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const doc = structuredClone(parsed) as Record<string, unknown>;
const bodyRaw = doc.正文;
if (!bodyRaw || typeof bodyRaw !== "object" || Array.isArray(bodyRaw)) {
return null;
}
const body = bodyRaw as Record<string, unknown>;
const ruleId = flowParamText(step.params, "rule_id");
if (!ruleId) return null;
let changed = false;
for (const key of ["rules", "records"] as const) {
const rows = body[key];
if (!Array.isArray(rows)) continue;
const next = rows.filter((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
return (
String((item as Record<string, unknown>).rule_id ?? "").trim() !==
ruleId
);
});
if (next.length !== rows.length) {
body[key] = next;
changed = true;
}
}
if (!changed) return null;
doc.正文 = body;
return JSON.stringify(doc, null, 2);
}
/** 实例步 depends_on:具体实例钉最近已验收规则实例;其余跟原型。 */ /** 实例步 depends_on:具体实例钉最近已验收规则实例;其余跟原型。 */
function resolveInstanceDependsOn(params: { function resolveInstanceDependsOn(params: {
flow: CreationFlow; flow: CreationFlow;
+11
View File
@@ -622,6 +622,17 @@ export function extractUnitContentFromDraft(
return null; return null;
} }
/** 从图上删除产物时拿掉该单位的验收切片 */
export function dropAcceptedUnitContent(
existingRaw: unknown,
unitId: string,
): string {
const store = parseAcceptedContentStore(existingRaw);
const id = normalizeCreationUnitId(unitId);
if (id) delete store.units[id];
return JSON.stringify(store, null, 2);
}
/** 写入/覆盖某一单位的最后验收内容 */ /** 写入/覆盖某一单位的最后验收内容 */
export function upsertAcceptedUnitContent( export function upsertAcceptedUnitContent(
existingRaw: unknown, existingRaw: unknown,
+31
View File
@@ -13,6 +13,36 @@ export const MAINTAIN_GENERATE_TAG = "运行.本轮.旁观.生成抽样";
type PoolEntry = { id?: string; 内容?: string; 权重?: number; weight?: number }; type PoolEntry = { id?: string; 内容?: string; 权重?: number; weight?: number };
const DIRECTION_KINDS = new Set(["方向", "方向池", "type", "类型", "类型池"]);
const ELEMENT_KINDS = new Set(["元素", "元素池", "enum", "枚举", "枚举池"]);
function poolKindToken(pool: Record<string, unknown>): string | undefined {
return (
asString(pool.池型) ??
asString(pool.kind) ??
asString(pool.类型) ??
asString(pool.pool_type)
);
}
/** 方向池给 LLM 当创作范围,不能程序加权抽。 */
export function isElementPool(pool: Record<string, unknown>): boolean {
const kind = poolKindToken(pool);
if (kind && DIRECTION_KINDS.has(kind)) return false;
if (kind && ELEMENT_KINDS.has(kind)) return true;
const dirs = pool.方向;
const hasDirection =
(dirs != null &&
typeof dirs === "object" &&
!Array.isArray(dirs) &&
Object.keys(dirs as object).length > 0) ||
(Array.isArray(dirs) && dirs.length > 0);
const entries = pool.条目 ?? pool.entries;
const hasEntries = Array.isArray(entries) && entries.length > 0;
if (hasDirection && !hasEntries) return false;
return hasEntries;
}
function asString(v: unknown): string | undefined { function asString(v: unknown): string | undefined {
if (typeof v !== "string") return undefined; if (typeof v !== "string") return undefined;
const t = v.trim(); const t = v.trim();
@@ -61,6 +91,7 @@ export function buildChanceBatchFromGenerationRule(
for (const poolRaw of pools) { for (const poolRaw of pools) {
if (!poolRaw || typeof poolRaw !== "object" || Array.isArray(poolRaw)) continue; if (!poolRaw || typeof poolRaw !== "object" || Array.isArray(poolRaw)) continue;
const pool = poolRaw as Record<string, unknown>; const pool = poolRaw as Record<string, unknown>;
if (!isElementPool(pool)) continue;
const poolId = const poolId =
asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称); asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称);
const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined; const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined;
+89 -26
View File
@@ -28,6 +28,8 @@ import {
hasSelectableCreationWork, hasSelectableCreationWork,
hasAcceptedCloserStep, hasAcceptedCloserStep,
removeUnstartedInstance, removeUnstartedInstance,
removeRepeatableInstance,
pruneRepeatableArtifactContent,
looksLikeCreationFlowDoc, looksLikeCreationFlowDoc,
parseCreationFlow, parseCreationFlow,
pickRecordedStepId, pickRecordedStepId,
@@ -37,7 +39,6 @@ import {
parseRecipeYaml, parseRecipeYaml,
parseSelectedRecipeRef, parseSelectedRecipeRef,
resolveDesignStepBinding, resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag, isProgressPointerTag,
stringifyCreationFlow, stringifyCreationFlow,
stripReviseSteps, stripReviseSteps,
@@ -776,31 +777,6 @@ modules:
美学纲领与交互范式: "shown", 美学纲领与交互范式: "shown",
}); });
expect(
shouldSkipModuleOpening({
demand: "丧尸世界只有我不会被感染",
dependsOn: [],
}),
).toBe(true);
expect(
shouldSkipModuleOpening({
demand: "丧尸世界只有我不会被感染",
dependsOn: ["美学纲领与交互范式"],
}),
).toBe(false);
expect(
shouldSkipModuleOpening({
demand: "",
dependsOn: [],
}),
).toBe(false);
expect(
shouldSkipModuleOpening({
demand: "已有需求",
dependsOn: [],
phase: "shown",
}),
).toBe(false);
expect(isProgressPointerTag("创作.当前步骤")).toBe(true); expect(isProgressPointerTag("创作.当前步骤")).toBe(true);
expect(isProgressPointerTag("设计.美学纲领与交互范式")).toBe(false); expect(isProgressPointerTag("设计.美学纲领与交互范式")).toBe(false);
expect(isProgressPointerTag("设计.创作流程")).toBe(false); expect(isProgressPointerTag("设计.创作流程")).toBe(false);
@@ -1253,6 +1229,93 @@ modules:
expect(accepted.steps.some((s) => s.id === "生成规则#1")).toBe(true); expect(accepted.steps.some((s) => s.id === "生成规则#1")).toBe(true);
}); });
it("removeRepeatableInstance drops accepted spawn and closer dep", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "role": "prototype", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#1", "name": "生成规则", "role": "instance", "from": "生成规则", "depends_on": ["美学纲领与交互范式"], "params": { "rule_id": "core-female-roles" } },
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式", "生成规则#1"] }
]
}`)!;
const dropped = removeRepeatableInstance({
flow,
stepId: "生成规则#1",
catalog: sampleCatalog,
});
expect("error" in dropped).toBe(false);
if ("error" in dropped) return;
expect(dropped.flow.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"生成规则",
"开场白与开场变量",
]);
expect(
dropped.flow.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式"]);
});
it("removeRepeatableInstance blocks when another instance depends on it", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "role": "prototype", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#1", "name": "生成规则", "role": "instance", "from": "生成规则", "depends_on": ["美学纲领与交互范式"], "params": { "target": "4位核心女性", "rule_id": "core-female-roles" } },
{ "id": "具体实例", "name": "具体实例", "role": "prototype", "depends_on": ["生成规则"] },
{ "id": "具体实例#1", "name": "具体实例", "role": "instance", "from": "具体实例", "depends_on": ["生成规则#1"] }
]
}`)!;
const blocked = removeRepeatableInstance({
flow,
stepId: "生成规则#1",
catalog: sampleCatalog,
});
expect(blocked).toMatchObject({ error: expect.stringContaining("具体实例") });
const ok = removeRepeatableInstance({
flow,
stepId: "具体实例#1",
catalog: sampleCatalog,
});
expect("error" in ok).toBe(false);
});
it("pruneRepeatableArtifactContent strips the deleted rule_id", () => {
const raw = JSON.stringify({
schema: "context-fragment.v1",
技能: "生成规则",
brief: "两条规则",
正文: {
rules: [
{ rule_id: "core-female-roles", 对象: "女臣" },
{ rule_id: "zombies", 对象: "丧尸" },
],
},
});
const step = {
id: "生成规则#1",
name: "生成规则",
role: "instance" as const,
depends_on: [],
params: { rule_id: "core-female-roles" },
};
const pruned = JSON.parse(
pruneRepeatableArtifactContent({
raw,
step,
remainingSameModule: 1,
}),
);
expect(pruned.正文.rules.map((r) => r.rule_id)).toEqual(["zombies"]);
expect(
pruneRepeatableArtifactContent({
raw,
step,
remainingSameModule: 0,
}),
).toBe("");
});
it("blocks 具体实例 spawn until a 生成规则 instance is accepted", () => { it("blocks 具体实例 spawn until a 生成规则 instance is accepted", () => {
const flow = parseCreationFlow(`{ const flow = parseCreationFlow(`{
"steps": [ "steps": [
+58
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildChanceBatchFromGenerationRule, buildChanceBatchFromGenerationRule,
isElementPool,
runMaintainNeedGenerateSampling, runMaintainNeedGenerateSampling,
} from "../src/skills/maintain-need-generate.js"; } from "../src/skills/maintain-need-generate.js";
@@ -40,4 +41,61 @@ describe("maintain need_generate sampling", () => {
expect(result?.schema).toBe("chance.batch.v1"); expect(result?.schema).toBe("chance.batch.v1");
expect(result?.results[0]?.id).toBe("pool:mood"); expect(result?.results[0]?.id).toBe("pool:mood");
}); });
it("treats 方向池 as non-element even if leftover 条目 exist", () => {
expect(
isElementPool({
池型: "方向",
方向: { 冷焰系: "静、刺、余烬不散,如…" },
条目: [{ id: "should-not-draw" }],
}),
).toBe(false);
});
it("skips 方向池 when building chance batch", () => {
const rules = JSON.stringify({
正文: {
rules: [
{
rule_id: "stand",
池: [
{
pool_id: "ability",
池型: "方向",
方向: { 时空操作系: "暂停、加速、回溯。例如 The World" },
},
{
pool_id: "stand-type",
池型: "元素",
条目: [{ id: "close-range", 权重: 1 }],
},
],
},
],
},
});
const batch = buildChanceBatchFromGenerationRule(rules, "stand");
expect(batch).toHaveLength(1);
expect(batch![0]!.id).toBe("pool:stand-type");
});
it("returns null when a rule only has 方向池", () => {
const rules = JSON.stringify({
正文: {
rules: [
{
rule_id: "nickname",
池: [
{
pool_id: "nick-style",
池型: "方向",
方向: { 自然现象系: "以天气、地理命名" },
},
],
},
],
},
});
expect(buildChanceBatchFromGenerationRule(rules, "nickname")).toBeNull();
});
}); });
+26
View File
@@ -658,6 +658,32 @@ describe("phase machine", () => {
expect(left.effects).toEqual([]); expect(left.effects).toEqual([]);
}); });
it("leaving from review_artifact returns to the pick graph", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "生成规则#1",
outputTags: ["设计.生成规则"],
});
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
pendingArtifactId: artifact.id,
artifacts: [artifact],
currentWorkerId: "design-step",
currentStepId: "生成规则#1",
};
const left = applyEvent(session, {
type: "user_left_creation_step",
payload: {},
});
expect(left.session.phase).toBe("waiting_user");
expect(left.session.waitingReason?.kind).toBe("pick_creation_step");
expect(left.session.pendingArtifactId).toBeUndefined();
expect(left.session.currentStepId).toBeUndefined();
});
it("replan from the pick graph runs design-flow", () => { it("replan from the pick graph runs design-flow", () => {
let session = createSession("default"); let session = createSession("default");
session = { session = {
+468 -1
View File
@@ -3,12 +3,36 @@ import {
createMockMainAgentResponse, createMockMainAgentResponse,
createMockToolCall, createMockToolCall,
MockLlmProvider, MockLlmProvider,
type LlmProvider,
} from "../src/llm/client.js"; } from "../src/llm/client.js";
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js"; import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
import { createSession } from "../src/runtime/phase-machine.js"; import { createArtifact, createSession } from "../src/runtime/phase-machine.js";
import { Blackboard } from "../src/blackboard/blackboard.js"; import { Blackboard } from "../src/blackboard/blackboard.js";
import { parseCreationFlow } from "../src/skills/creation-flow.js"; import { parseCreationFlow } from "../src/skills/creation-flow.js";
function countLlmCalls(llm: MockLlmProvider): { calls: () => number } {
let n = 0;
const bump = () => {
n += 1;
};
const origComplete = llm.complete.bind(llm);
llm.complete = (async (messages, options) => {
bump();
return origComplete(messages, options);
}) as LlmProvider["complete"];
const origStream = llm.completeStream.bind(llm);
llm.completeStream = (async (messages, options, callbacks) => {
bump();
return origStream(messages, options, callbacks);
}) as LlmProvider["completeStream"];
const origTools = llm.completeWithTools.bind(llm);
llm.completeWithTools = (async (messages, options) => {
bump();
return origTools(messages, options);
}) as LlmProvider["completeWithTools"];
return { calls: () => n };
}
describe("phase runtime", () => { describe("phase runtime", () => {
it("auto-loads default orchestrator and awaits first user input", async () => { it("auto-loads default orchestrator and awaits first user input", async () => {
const runtime = new PhaseRuntime(); const runtime = new PhaseRuntime();
@@ -178,6 +202,120 @@ describe("phase runtime", () => {
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step"); expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
}); });
it("picking a node shows module opening instead of calling LLM", async () => {
const llm = new MockLlmProvider([
JSON.stringify({
outputs: { "设计.舞台骨架": { 范围: "不该到这一步" } },
summary: "不应生成",
askUser: null,
}),
]);
const llmCalls = countLlmCalls(llm);
const runtime = new PhaseRuntime({ llm });
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "舞台骨架",
name: "舞台骨架",
depends_on: [],
},
],
}),
source: "runtime",
});
await runtime.enterSeededCreationPick();
await runtime.pickCreationStep("舞台骨架");
const session = runtime.getSession();
expect(session.phase).toBe("waiting_user");
expect(session.waitingReason?.kind).toBe("worker_questions");
if (session.waitingReason?.kind === "worker_questions") {
expect(session.waitingReason.questions[0]?.id).toBe("module-opening");
expect(session.waitingReason.questions[0]?.prompt).toContain(
"玩家实际会待着玩的地方",
);
}
expect(llmCalls.calls()).toBe(0);
});
it("picking a root node still shows opening even if 用户.需求 already has text", async () => {
const llm = new MockLlmProvider([
JSON.stringify({
outputs: { "设计.舞台骨架": { 范围: "不该到这一步" } },
summary: "不应生成",
askUser: null,
}),
]);
const llmCalls = countLlmCalls(llm);
const runtime = new PhaseRuntime({ llm });
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "舞台骨架",
name: "舞台骨架",
depends_on: [],
},
],
}),
source: "runtime",
});
runtime.getBlackboard().write({
tag: "用户.需求",
content: "丧尸世界只有我不会被感染",
source: "user",
});
runtime.getSession().slots["用户.需求"] = "丧尸世界只有我不会被感染";
await runtime.enterSeededCreationPick();
await runtime.pickCreationStep("舞台骨架");
expect(runtime.getSession().waitingReason?.kind).toBe("worker_questions");
expect(llmCalls.calls()).toBe(0);
});
it("leaving during opening and picking again shows opening instead of calling LLM", async () => {
const llm = new MockLlmProvider([
JSON.stringify({
outputs: { "设计.舞台骨架": { 范围: "不该到这一步" } },
summary: "不应生成",
askUser: null,
}),
]);
const llmCalls = countLlmCalls(llm);
const runtime = new PhaseRuntime({ llm });
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "舞台骨架",
name: "舞台骨架",
depends_on: [],
},
],
}),
source: "runtime",
});
await runtime.enterSeededCreationPick();
await runtime.pickCreationStep("舞台骨架");
expect(runtime.getSession().waitingReason?.kind).toBe("worker_questions");
await runtime.leaveCreationStep();
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
await runtime.pickCreationStep("舞台骨架");
expect(runtime.getSession().waitingReason?.kind).toBe("worker_questions");
expect(llmCalls.calls()).toBe(0);
});
it("unusable main agent output without DAG starts design-flow", async () => { it("unusable main agent output without DAG starts design-flow", async () => {
const llm = new MockLlmProvider(["Let me think about routing"]); const llm = new MockLlmProvider(["Let me think about routing"]);
const runtime = new PhaseRuntime({ llm, autoStubWorker: true }); const runtime = new PhaseRuntime({ llm, autoStubWorker: true });
@@ -294,6 +432,335 @@ describe("phase runtime", () => {
).toEqual(["美学纲领与交互范式"]); ).toEqual(["美学纲领与交互范式"]);
}); });
it("leaveCreationStep from review_artifact returns to pick without unsawning a filled instance", async () => {
const artifact = createArtifact({
workerId: "design-step",
stepId: "生成规则#1",
outputTags: ["设计.生成规则"],
});
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "review_artifact", artifactId: artifact.id };
session.pendingArtifactId = artifact.id;
session.currentWorkerId = "design-step";
session.currentStepId = "生成规则#1";
session.artifacts = [artifact];
session.slots = { startupCompleted: true };
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: ["美学纲领与交互范式"],
},
],
}),
source: "runtime",
});
blackboard.write({
tag: "创作.当前步骤",
content: "生成规则#1",
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.leaveCreationStep();
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
expect(runtime.getSession().pendingArtifactId).toBeUndefined();
const flow = parseCreationFlow(
runtime.getBlackboard().getContentByTag("设计.创作流程"),
);
expect(flow?.steps.map((s) => s.id)).toContain("生成规则#1");
});
it("abortCurrentRun restores the snapshot and waits instead of rerunning", async () => {
const runtime = new PhaseRuntime({ autoStubWorker: true });
await runtime.start();
await runtime.submitInput("网恋对象对话");
await runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "编排创作流程",
workerId: "design-flow",
requiresApproval: false,
}),
);
const snap = runtime.getLastWorkerRunSnapshot();
expect(snap?.workerId).toBe("design-flow");
const artifactsAtSnapshot = snap!.runtimeSession.artifacts.length;
const session = runtime.getSession();
session.phase = "running";
session.waitingReason = undefined;
session.currentWorkerId = "design-flow";
runtime.abortCurrentRun();
expect(runtime.getSession().phase).toBe("waiting_user");
expect(runtime.getSession().currentWorkerId).toBeUndefined();
expect(runtime.getSession().artifacts.length).toBe(artifactsAtSnapshot);
});
it("reentering an accepted instance reopens its artifact for revision", async () => {
const artifact = {
...createArtifact({
workerId: "design-step",
stepId: "生成规则#1",
outputTags: ["设计.生成规则"],
}),
status: "accepted",
};
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "pick_creation_step" };
session.slots = {
startupCompleted: true,
creationAcceptedUnits: ["生成规则#1"],
};
session.artifacts = [artifact];
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: ["美学纲领与交互范式"],
params: { target: "4位核心女性" },
},
],
}),
source: "runtime",
});
blackboard.write({
tag: "创作.已验收单位",
content: JSON.stringify(["生成规则#1"]),
source: "runtime",
});
blackboard.write({
tag: "设计.生成规则",
content: "已有规则正文",
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.pickCreationStep("生成规则#1", undefined, { reenter: true });
const next = runtime.getSession();
expect(next.waitingReason?.kind).toBe("review_artifact");
if (next.waitingReason?.kind === "review_artifact") {
expect(next.waitingReason.artifactId).toBe(artifact.id);
}
expect(next.pendingArtifactId).toBe(artifact.id);
expect(next.currentStepId).toBe("生成规则#1");
expect(next.phase).toBe("waiting_user");
expect(runtime.getBlackboard().getContentByTag("创作.继承修改")).toBe("1");
expect(next.slots.revisionTargetArtifactId).toBe(artifact.id);
expect(
next.artifacts.find((a) => a.id === artifact.id)?.status,
).toBe("under_review");
});
it("revising a reentered accepted instance does not bounce to the node graph", async () => {
const artifact = {
...createArtifact({
workerId: "design-step",
stepId: "生成规则#1",
outputTags: ["设计.生成规则"],
}),
status: "accepted",
};
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "pick_creation_step" };
session.slots = {
startupCompleted: true,
creationAcceptedUnits: ["生成规则#1"],
};
session.artifacts = [artifact];
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: ["美学纲领与交互范式"],
params: { target: "4位核心女性" },
},
],
}),
source: "runtime",
});
blackboard.write({
tag: "创作.已验收单位",
content: JSON.stringify(["生成规则#1"]),
source: "runtime",
});
blackboard.write({
tag: "设计.生成规则",
content: "已有规则正文",
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.pickCreationStep("生成规则#1", undefined, { reenter: true });
await runtime.rejectArtifact("把对象改成四位男性");
const next = runtime.getSession();
expect(next.waitingReason?.kind).not.toBe("pick_creation_step");
expect(next.phase).toBe("running");
expect(next.currentWorkerId).toBe("design-step");
expect(next.slots.revisionTargetArtifactId).toBe(artifact.id);
});
it("deleteCreationStep removes an accepted repeatable instance", async () => {
const artifact = {
...createArtifact({
workerId: "design-step",
stepId: "生成规则#1",
outputTags: ["设计.生成规则"],
}),
status: "accepted",
};
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "pick_creation_step" };
session.slots = {
startupCompleted: true,
creationAcceptedUnits: ["美学纲领与交互范式", "生成规则#1"],
};
session.artifacts = [artifact];
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: ["美学纲领与交互范式"],
params: { target: "4位核心女性", rule_id: "core-female-roles" },
},
{
id: "开场白与开场变量",
name: "开场白与开场变量",
depends_on: ["美学纲领与交互范式", "生成规则#1"],
},
],
}),
source: "runtime",
});
blackboard.write({
tag: "创作.已验收单位",
content: JSON.stringify(["美学纲领与交互范式", "生成规则#1"]),
source: "runtime",
});
blackboard.write({
tag: "设计.生成规则",
content: JSON.stringify({
schema: "context-fragment.v1",
技能: "生成规则",
brief: "女臣",
正文: { rules: [{ rule_id: "core-female-roles", 对象: "女臣" }] },
}),
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.deleteCreationStep("生成规则#1");
const flow = parseCreationFlow(
runtime.getBlackboard().getContentByTag("设计.创作流程"),
);
expect(flow?.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"生成规则",
"开场白与开场变量",
]);
expect(
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式"]);
expect(runtime.getSession().slots.creationAcceptedUnits).toEqual([
"美学纲领与交互范式",
]);
expect(
runtime.getSession().artifacts.find((a) => a.id === artifact.id)?.status,
).toBe("rejected");
await expect(
runtime.deleteCreationStep("美学纲领与交互范式"),
).rejects.toThrow(/可增殖/);
});
it("prepareEnterPlay seals when closer is already accepted", () => { it("prepareEnterPlay seals when closer is already accepted", () => {
const session = createSession("default"); const session = createSession("default");
const blackboard = new Blackboard(); const blackboard = new Blackboard();
+470 -54
View File
@@ -197,8 +197,10 @@ function hideFlowNodeMenu() {
const flowMenuState = { const flowMenuState = {
stepId: null, stepId: null,
name: "", name: "",
label: "",
hasArtifact: false, hasArtifact: false,
canReenter: false, canReenter: false,
canDelete: false,
node: null, node: null,
handlers: null, handlers: null,
}; };
@@ -224,12 +226,16 @@ function showFlowNodeMenu(node, x, y, handlers) {
if (!stepId) return; if (!stepId) return;
flowMenuState.stepId = stepId; flowMenuState.stepId = stepId;
flowMenuState.name = node.getAttribute("data-flow-done-name") || ""; flowMenuState.name = node.getAttribute("data-flow-done-name") || "";
flowMenuState.label =
node.getAttribute("data-flow-label") || flowMenuState.name || stepId;
flowMenuState.hasArtifact = node.getAttribute("data-has-artifact") === "1"; flowMenuState.hasArtifact = node.getAttribute("data-has-artifact") === "1";
flowMenuState.canReenter = node.getAttribute("data-can-reenter") === "1"; flowMenuState.canReenter = node.getAttribute("data-can-reenter") === "1";
flowMenuState.canDelete = node.getAttribute("data-can-delete") === "1";
flowMenuState.node = node; flowMenuState.node = node;
flowMenuState.handlers = handlers; flowMenuState.handlers = handlers;
const viewBtn = document.getElementById("flow-menu-view"); const viewBtn = document.getElementById("flow-menu-view");
const reenterBtn = document.getElementById("flow-menu-reenter"); const reenterBtn = document.getElementById("flow-menu-reenter");
const deleteBtn = document.getElementById("flow-menu-delete");
if (viewBtn) { if (viewBtn) {
viewBtn.disabled = !flowMenuState.hasArtifact; viewBtn.disabled = !flowMenuState.hasArtifact;
viewBtn.title = flowMenuState.hasArtifact ? "" : "这一步还没有产物"; viewBtn.title = flowMenuState.hasArtifact ? "" : "这一步还没有产物";
@@ -237,6 +243,9 @@ function showFlowNodeMenu(node, x, y, handlers) {
if (reenterBtn) { if (reenterBtn) {
reenterBtn.toggleAttribute("hidden", !flowMenuState.canReenter); reenterBtn.toggleAttribute("hidden", !flowMenuState.canReenter);
} }
if (deleteBtn) {
deleteBtn.toggleAttribute("hidden", !flowMenuState.canDelete);
}
placeMenu(menu, x, y); placeMenu(menu, x, y);
} }
@@ -248,11 +257,13 @@ function wireFlowNodeMenu() {
const action = e.target.closest("[data-flow-menu-action]")?.getAttribute("data-flow-menu-action"); const action = e.target.closest("[data-flow-menu-action]")?.getAttribute("data-flow-menu-action");
const stepId = flowMenuState.stepId; const stepId = flowMenuState.stepId;
const name = flowMenuState.name; const name = flowMenuState.name;
const label = flowMenuState.label || name;
const handlers = flowMenuState.handlers; const handlers = flowMenuState.handlers;
hideFlowNodeMenu(); hideFlowNodeMenu();
if (!stepId || !action) return; if (!stepId || !action) return;
if (action === "view") handlers?.onViewStepArtifact?.(stepId, name); if (action === "view") handlers?.onViewStepArtifact?.(stepId, name);
if (action === "reenter") handlers?.onReenterCreationStep?.(stepId); if (action === "reenter") handlers?.onReenterCreationStep?.(stepId);
if (action === "delete") handlers?.onDeleteCreationStep?.(stepId, label);
}); });
} }
@@ -2406,6 +2417,17 @@ function renderOneGenerationRuleCard(rule, index) {
</header>`; </header>`;
const blocks = []; const blocks = [];
if (rule.设计判断 && typeof rule.设计判断 === "object") {
const d = rule.设计判断;
const unlist = (v) =>
Array.isArray(v) && v.length ? v.map(String).join("、") : "";
blocks.push(
`<div class="skill-sub"><div class="skill-sub-title">设计判断</div>${skillChipRow([
unlist(d.不可穷举项) && `不可穷举 · ${unlist(d.不可穷举项)}`,
unlist(d.封闭集合) && `封闭 · ${unlist(d.封闭集合)}`,
])}${d.正交字段依据 ? `<p class="skill-pool-desc">${esc(String(d.正交字段依据))}</p>` : ""}</div>`,
);
}
if (rule.上下文策略 && typeof rule.上下文策略 === "object") { if (rule.上下文策略 && typeof rule.上下文策略 === "object") {
const flags = Object.entries(rule.上下文策略) const flags = Object.entries(rule.上下文策略)
.map(([k, v]) => `<span class="ws-slot-chip ${v ? "ws-slot-on" : "ws-slot-off"}">${esc(k)}</span>`) .map(([k, v]) => `<span class="ws-slot-chip ${v ? "ws-slot-on" : "ws-slot-off"}">${esc(k)}</span>`)
@@ -2424,9 +2446,13 @@ function renderOneGenerationRuleCard(rule, index) {
} }
if (rule.生成与描写 && typeof rule.生成与描写 === "object") { if (rule.生成与描写 && typeof rule.生成与描写 === "object") {
const g = rule.生成与描写; const g = rule.生成与描写;
const methodKeys = ["依据", "方法", "硬约束", "字段间约束", "变化维度", "禁止项", "去重规则", "校验"]; const methodKeys = ["依据", "工作流", "方法", "硬约束", "字段间约束", "变化维度", "禁止项", "去重规则", "反模式", "校验"];
const extra = [
renderGenerationRecipeHtml(g.配方),
renderGenerationExpressHtml(g.表现),
].filter(Boolean);
blocks.push( blocks.push(
`<div class="skill-sub"><div class="skill-sub-title">生成与描写</div>${methodKeys `<div class="skill-sub"><div class="skill-sub-title">生成与描写</div>${extra.join("")}${methodKeys
.filter((k) => Array.isArray(g[k]) && g[k].length) .filter((k) => Array.isArray(g[k]) && g[k].length)
.map( .map(
(k) => (k) =>
@@ -2435,6 +2461,9 @@ function renderOneGenerationRuleCard(rule, index) {
.join("")}</div>`, .join("")}</div>`,
); );
} }
if (Array.isArray(rule.分级表现) && rule.分级表现.length) {
blocks.push(renderGenerationGradeHtml(rule.分级表现));
}
if (rule.产物格式 && typeof rule.产物格式 === "object") { if (rule.产物格式 && typeof rule.产物格式 === "object") {
blocks.push(renderProductSchemaCard(rule.产物格式)); blocks.push(renderProductSchemaCard(rule.产物格式));
} }
@@ -2442,19 +2471,7 @@ function renderOneGenerationRuleCard(rule, index) {
if (pools.length) { if (pools.length) {
blocks.push( blocks.push(
`<div class="skill-sub"><div class="skill-sub-title">池(${pools.length})</div><div class="skill-pool-grid">${pools `<div class="skill-sub"><div class="skill-sub-title">池(${pools.length})</div><div class="skill-pool-grid">${pools
.map((p) => { .map((p) => renderGenerationPoolCard(p))
if (!p || typeof p !== "object") return "";
const n = Array.isArray(p.条目) ? p.条目.length : 0;
return `<article class="skill-pool-card">
<div class="skill-pool-name">${esc(String(p.名称 || p.pool_id || "池"))}</div>
${skillChipRow([
p.绑定字段 && `绑 · ${p.绑定字段}`,
p.用途 && String(p.用途),
n ? `${n} 条` : "",
])}
${p.说明 ? `<p class="skill-pool-desc">${esc(clampPreviewText(String(p.说明), 120))}</p>` : ""}
</article>`;
})
.join("")}</div></div>`, .join("")}</div></div>`,
); );
} }
@@ -2462,6 +2479,137 @@ function renderOneGenerationRuleCard(rule, index) {
return `<article class="skill-rule-card">${head}${blocks.join("")}</article>`; return `<article class="skill-rule-card">${head}${blocks.join("")}</article>`;
} }
function renderGenerationRecipeHtml(rows) {
if (!Array.isArray(rows) || !rows.length) return "";
const cards = rows
.map((row) => {
if (!row || typeof row !== "object") return "";
const comps = Array.isArray(row.组件) ? row.组件.map(String).join(" × ") : "";
return `<article class="skill-pool-card is-direction">
<div class="skill-pool-name">配方 · ${esc(String(row.目标字段 || "字段"))}</div>
${skillChipRow([comps && `组件 · ${comps}`])}
${row.组装 ? `<p class="skill-pool-desc">${esc(String(row.组装))}</p>` : ""}
</article>`;
})
.filter(Boolean);
return cards.length
? `<div class="skill-method"><span class="skill-method-label">配方</span><div class="skill-pool-grid">${cards.join("")}</div></div>`
: "";
}
function renderGenerationExpressHtml(rows) {
if (!Array.isArray(rows) || !rows.length) return "";
const cards = rows
.map((row) => {
if (!row || typeof row !== "object") return "";
const ex = Array.isArray(row.例子) ? row.例子.filter(Boolean) : [];
return `<article class="skill-pool-card is-direction">
<div class="skill-pool-name">${esc(String(row.针对 || "表现"))}</div>
${row.概括 ? `<p class="skill-pool-desc">${esc(String(row.概括))}</p>` : ""}
${
ex.length
? `<ul class="artifact-list">${ex.map((x) => `<li>${esc(String(x))}</li>`).join("")}</ul>`
: ""
}
</article>`;
})
.filter(Boolean);
return cards.length
? `<div class="skill-method"><span class="skill-method-label">表现</span><div class="skill-pool-grid">${cards.join("")}</div></div>`
: "";
}
function renderGenerationGradeHtml(rows) {
const blocks = rows
.map((row) => {
if (!row || typeof row !== "object") return "";
const grades = Array.isArray(row.档位) ? row.档位 : [];
const tr = grades
.map((g) => {
if (!g || typeof g !== "object") return "";
return `<tr>
<td><code>${esc(String(g.值 || ""))}</code></td>
<td>${esc(String(g.概括 || ""))}</td>
<td>${esc(String(g.表象 || ""))}</td>
</tr>`;
})
.join("");
return `<div class="skill-sub">
<div class="skill-sub-title">分级表现 · ${esc(String(row.字段 || ""))}</div>
${row.说明 ? `<p class="skill-pool-desc">${esc(String(row.说明))}</p>` : ""}
${
tr
? `<div class="skill-table-wrap"><table class="skill-field-table"><thead><tr><th>档</th><th>概括</th><th>表象</th></tr></thead><tbody>${tr}</tbody></table></div>`
: ""
}
</div>`;
})
.filter(Boolean);
return blocks.join("");
}
function generationPoolKind(pool) {
const raw = String(pool?.池型 || pool?.kind || pool?.类型 || "").trim();
if (/方向|类型|type/i.test(raw)) return "方向";
if (/元素|枚举|enum/i.test(raw)) return "元素";
if (pool?.方向 && typeof pool.方向 === "object") return "方向";
if (Array.isArray(pool?.条目) && pool.条目.length) return "元素";
return "";
}
function generationPoolDirections(pool) {
const dirs = pool?.方向;
if (!dirs) return [];
if (Array.isArray(dirs)) {
return dirs
.map((row) => {
if (typeof row === "string") return { name: row, text: "" };
if (!row || typeof row !== "object") return null;
return {
name: String(row.名 || row.name || row.方向 || "").trim(),
text: String(row.说明 || row.description || row.内容 || "").trim(),
};
})
.filter((x) => x && (x.name || x.text));
}
if (typeof dirs === "object") {
return Object.entries(dirs).map(([name, text]) => ({
name,
text: text == null ? "" : String(text),
}));
}
return [];
}
function renderGenerationPoolCard(p) {
if (!p || typeof p !== "object") return "";
const kind = generationPoolKind(p);
const dirs = generationPoolDirections(p);
const n = Array.isArray(p.条目) ? p.条目.length : 0;
const dirHtml = dirs.length
? `<dl class="skill-pool-dirs">${dirs
.map(
(d) =>
`<div class="skill-pool-dir"><dt>${esc(d.name || "方向")}</dt>${
d.text ? `<dd>${esc(clampPreviewText(d.text, 160))}</dd>` : ""
}</div>`,
)
.join("")}</dl>`
: "";
return `<article class="skill-pool-card${kind === "方向" ? " is-direction" : ""}">
<div class="skill-pool-name">${esc(String(p.名称 || p.pool_id || "池"))}</div>
${skillChipRow([
kind && `${kind}池`,
p.绑定字段 && `绑 · ${p.绑定字段}`,
p.用途 && String(p.用途),
kind === "元素" && n ? `${n} 条` : "",
kind === "方向" && dirs.length ? `${dirs.length} 向` : "",
])}
${p.说明 ? `<p class="skill-pool-desc">${esc(clampPreviewText(String(p.说明), 160))}</p>` : ""}
${dirHtml}
</article>`;
}
function renderProductSchemaCard(fmt) { function renderProductSchemaCard(fmt) {
const schema = fmt.schema && typeof fmt.schema === "object" ? fmt.schema : null; const schema = fmt.schema && typeof fmt.schema === "object" ? fmt.schema : null;
const fields = schema const fields = schema
@@ -2474,6 +2622,9 @@ function renderProductSchemaCard(fmt) {
const req = spec.required === true ? "必填" : spec.required === false ? "可选" : "—"; const req = spec.required === true ? "必填" : spec.required === false ? "可选" : "—";
const desc = spec.description != null ? clampPreviewText(String(spec.description), 80) : ""; const desc = spec.description != null ? clampPreviewText(String(spec.description), 80) : "";
const extra = []; const extra = [];
if (spec.粒度) extra.push(`粒度 ${spec.粒度}`);
if (spec.创作) extra.push(`创作 ${spec.创作}`);
if (spec.地板) extra.push(clampPreviewText(String(spec.地板), 60));
if (Array.isArray(spec.allowed_values) && spec.allowed_values.length) { if (Array.isArray(spec.allowed_values) && spec.allowed_values.length) {
extra.push( extra.push(
`枚举 ${spec.allowed_values `枚举 ${spec.allowed_values
@@ -3576,6 +3727,7 @@ function renderCreationFlowView(flowView, opts = {}) {
<div class="flow-dag" data-flow-dag> <div class="flow-dag" data-flow-dag>
<svg class="flow-dag-edges" aria-hidden="true"></svg> <svg class="flow-dag-edges" aria-hidden="true"></svg>
<div class="flow-dag-rows">${rows}</div> <div class="flow-dag-rows">${rows}</div>
<aside class="flow-dag-hint" hidden role="status"></aside>
</div> </div>
${suggestionHtml} ${suggestionHtml}
</section>`; </section>`;
@@ -3670,6 +3822,11 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
const selectable = Boolean(s.selectable) && interactive; const selectable = Boolean(s.selectable) && interactive;
const doneMenu = !isProto && runState === "done"; const doneMenu = !isProto && runState === "done";
const canReenter = doneMenu && interactive; const canReenter = doneMenu && interactive;
const canDelete =
doneMenu &&
interactive &&
s.role === "instance" &&
Boolean(s.repeatable);
const runLabel = isProto const runLabel = isProto
? selectable ? selectable
? "可增殖" ? "可增殖"
@@ -3685,22 +3842,28 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
? isProto ? isProto
? "增殖" ? "增殖"
: "进入" : "进入"
: ""; : canReenter
? "进入"
: "";
const actionHtml = actionLabel const actionHtml = actionLabel
? `<span class="flow-action">${esc(actionLabel)}</span>` ? `<span class="flow-action">${esc(actionLabel)}</span>`
: ""; : "";
const doneMenuBtn = doneMenu const doneMenuBtn = doneMenu
? `<span class="flow-node-menu-btn" data-flow-menu-trigger aria-hidden="true">⋯</span>` ? `<span class="flow-node-menu-btn" data-flow-menu-trigger aria-hidden="true">⋯</span>`
: ""; : "";
const blockedBy = Array.isArray(s.blockedBy) ? s.blockedBy : [];
const blockedAttr = blockedBy.length
? ` data-flow-blocked="${escAttr(JSON.stringify(blockedBy))}"`
: "";
const blocked = const blocked =
!selectable && !doneMenu && s.blockedBy?.length !selectable && !doneMenu && blockedBy.length
? ` title="还差:${esc(s.blockedBy.join("、"))}"` ? ` title="还差:${esc(blockedBy.join("、"))}"`
: isProto && selectable : isProto && selectable
? ` title="确认当前编排,点一下新开一条产物再进入"` ? ` title="确认当前编排,点一下新开一条产物再进入"`
: selectable : selectable
? ` title="确认当前编排并进入"` ? ` title="确认当前编排并进入"`
: doneMenu : canReenter
? ` title="查看产物或重新进入修改"` ? ` title="进入这条产物继续改,继承原文;新开一条请点原型上的「增殖」"`
: ""; : "";
const currentAttr = runState === "current" ? ' aria-current="step"' : ""; const currentAttr = runState === "current" ? ' aria-current="step"' : "";
const clickable = selectable || doneMenu; const clickable = selectable || doneMenu;
@@ -3708,9 +3871,9 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
const typeAttr = clickable ? ' type="button"' : ""; const typeAttr = clickable ? ' type="button"' : "";
const pickAttr = selectable ? ` data-flow-pick="${esc(s.id)}"` : ""; const pickAttr = selectable ? ` data-flow-pick="${esc(s.id)}"` : "";
const doneAttr = doneMenu const doneAttr = doneMenu
? ` data-flow-done="${esc(s.id)}" data-flow-done-name="${escAttr(s.name)}" data-has-artifact="${s.hasArtifact ? "1" : "0"}" data-can-reenter="${canReenter ? "1" : "0"}" aria-haspopup="menu" aria-label="已完成:${escAttr(s.title || s.name)}"` ? ` data-flow-done="${esc(s.id)}" data-flow-done-name="${escAttr(s.name)}" data-flow-label="${escAttr([s.name, s.title].filter(Boolean).join(" · ") || s.id)}" data-has-artifact="${s.hasArtifact ? "1" : "0"}" data-can-reenter="${canReenter ? "1" : "0"}" data-can-delete="${canDelete ? "1" : "0"}" aria-haspopup="menu" aria-label="${canReenter ? "进入继续修改" : "已完成"}:${escAttr(s.title || s.name)}"`
: ""; : "";
const clickableClass = selectable const clickableClass = selectable || canReenter
? " is-selectable" ? " is-selectable"
: doneMenu : doneMenu
? " is-done-menu" ? " is-done-menu"
@@ -3724,9 +3887,9 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
const originAttr = const originAttr =
origin?.fromId ? ` data-flow-origin="${escAttr(origin.fromId)}"` : ""; origin?.fromId ? ` data-flow-origin="${escAttr(origin.fromId)}"` : "";
const metaHtml = compact const metaHtml = compact
? `<span class="flow-spawn-meta">${actionHtml || doneMenuBtn || `<span class="flow-run">${esc(runLabel)}</span>`}</span>` ? `<span class="flow-spawn-meta">${actionHtml}${doneMenuBtn || (!actionHtml ? `<span class="flow-run">${esc(runLabel)}</span>` : "")}</span>`
: `${actionHtml}${doneMenuBtn}`; : `${actionHtml}${doneMenuBtn}`;
return `<${tag} class="flow-node${protoClass}${spawnClass}${clickableClass}${lockedClass}" data-flow-id="${escAttr(s.id)}" data-flow-name="${escAttr(s.name)}" data-flow-deps="${depsJson}" data-run="${escAttr(runState)}"${fromAttr}${originAttr}${currentAttr}${typeAttr}${pickAttr}${doneAttr}${blocked}> return `<${tag} class="flow-node${protoClass}${spawnClass}${clickableClass}${lockedClass}" data-flow-id="${escAttr(s.id)}" data-flow-name="${escAttr(s.name)}" data-flow-deps="${depsJson}" data-run="${escAttr(runState)}"${fromAttr}${originAttr}${blockedAttr}${currentAttr}${typeAttr}${pickAttr}${doneAttr}${blocked}>
<span class="flow-node-main"> <span class="flow-node-main">
${compact ? "" : `<span class="flow-run">${esc(runLabel)}</span>`} ${compact ? "" : `<span class="flow-run">${esc(runLabel)}</span>`}
<span class="flow-name">${nameLabel}${occ}</span> <span class="flow-name">${nameLabel}${occ}</span>
@@ -3741,6 +3904,47 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
</${tag}>`; </${tag}>`;
} }
function parseFlowJsonList(raw) {
try {
const list = JSON.parse(raw || "[]");
return Array.isArray(list) ? list.map(String).filter(Boolean) : [];
} catch {
return [];
}
}
function flowNodeHintName(el) {
if (!el) return "";
return (
el.getAttribute("data-flow-label") ||
el.querySelector(".flow-title")?.textContent?.trim() ||
el.getAttribute("data-flow-name") ||
el.getAttribute("data-flow-id") ||
""
);
}
function flowNodeRunLabel(el) {
const run = el?.getAttribute("data-run");
if (run === "done") return "已完成";
if (run === "current") return "进行中";
if (el?.classList.contains("is-selectable")) return "可进入";
if (el?.classList.contains("is-locked")) return "待前置";
return "未执行";
}
function flowDagNodeIndex(dag) {
const byId = new Map();
const byName = new Map();
for (const el of dag.querySelectorAll("[data-flow-id]")) {
const id = el.getAttribute("data-flow-id");
const name = el.getAttribute("data-flow-name");
if (id) byId.set(id, el);
if (name && !byName.has(name)) byName.set(name, el);
}
return (ref) => byId.get(String(ref)) || byName.get(String(ref)) || null;
}
function paintFlowDagEdges(dag) { function paintFlowDagEdges(dag) {
if (!dag) return; if (!dag) return;
const svg = dag.querySelector(".flow-dag-edges"); const svg = dag.querySelector(".flow-dag-edges");
@@ -3750,15 +3954,6 @@ function paintFlowDagEdges(dag) {
svg.replaceChildren(); svg.replaceChildren();
return; return;
} }
const byId = new Map();
const byName = new Map();
for (const el of nodes) {
const id = el.getAttribute("data-flow-id");
const name = el.getAttribute("data-flow-name");
if (id) byId.set(id, el);
if (name && !byName.has(name)) byName.set(name, el);
}
const findNode = (ref) => byId.get(ref) || byName.get(ref) || null;
const dagRect = dag.getBoundingClientRect(); const dagRect = dag.getBoundingClientRect();
const w = Math.max(dag.scrollWidth, dag.clientWidth, 1); const w = Math.max(dag.scrollWidth, dag.clientWidth, 1);
const h = Math.max(dag.scrollHeight, dag.clientHeight, 1); const h = Math.max(dag.scrollHeight, dag.clientHeight, 1);
@@ -3766,20 +3961,17 @@ function paintFlowDagEdges(dag) {
svg.setAttribute("height", String(h)); svg.setAttribute("height", String(h));
svg.setAttribute("viewBox", `0 0 ${w} ${h}`); svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
const findNode = flowDagNodeIndex(dag);
const paths = []; const paths = [];
for (const el of nodes) { for (const el of nodes) {
let deps = []; const deps = parseFlowJsonList(el.getAttribute("data-flow-deps"));
try { if (!deps.length) continue;
deps = JSON.parse(el.getAttribute("data-flow-deps") || "[]"); const toId = el.getAttribute("data-flow-id") || "";
} catch {
deps = [];
}
if (!Array.isArray(deps) || !deps.length) continue;
const toRect = el.getBoundingClientRect(); const toRect = el.getBoundingClientRect();
const x2 = toRect.left - dagRect.left + toRect.width / 2 + dag.scrollLeft; const x2 = toRect.left - dagRect.left + toRect.width / 2 + dag.scrollLeft;
const y2 = toRect.top - dagRect.top + dag.scrollTop; const y2 = toRect.top - dagRect.top + dag.scrollTop;
for (const ref of deps) { for (const ref of deps) {
const from = findNode(String(ref)); const from = findNode(ref);
if (!from || from === el) continue; if (!from || from === el) continue;
const fromId = from.getAttribute("data-flow-id") || ""; const fromId = from.getAttribute("data-flow-id") || "";
if (el.getAttribute("data-flow-from") === fromId) continue; if (el.getAttribute("data-flow-from") === fromId) continue;
@@ -3787,19 +3979,15 @@ function paintFlowDagEdges(dag) {
const x1 = const x1 =
fromRect.left - dagRect.left + fromRect.width / 2 + dag.scrollLeft; fromRect.left - dagRect.left + fromRect.width / 2 + dag.scrollLeft;
const y1 = fromRect.bottom - dagRect.top + dag.scrollTop; const y1 = fromRect.bottom - dagRect.top + dag.scrollTop;
const dy = Math.max(24, (y2 - y1) * 0.45); const dy = Math.max(28, (y2 - y1) * 0.42);
const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} C ${x1.toFixed(1)} ${(y1 + dy).toFixed(1)}, ${x2.toFixed(1)} ${(y2 - dy).toFixed(1)}, ${x2.toFixed(1)} ${y2.toFixed(1)}`; const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} C ${x1.toFixed(1)} ${(y1 + dy).toFixed(1)}, ${x2.toFixed(1)} ${(y2 - dy).toFixed(1)}, ${x2.toFixed(1)} ${y2.toFixed(1)}`;
const belong = el.getAttribute("data-flow-origin") === fromId; const belong = el.getAttribute("data-flow-origin") === fromId;
const active = const cls = `flow-dag-edge${belong ? " flow-dag-edge--belong" : ""}`;
from.classList.contains("is-selectable") ||
el.classList.contains("is-selectable") ||
from.getAttribute("data-run") === "done";
const cls = `flow-dag-edge${active ? " is-lit" : ""}${belong ? " flow-dag-edge--belong" : ""}`;
const marker = belong const marker = belong
? "url(#flow-dag-arrow-belong)" ? "url(#flow-dag-arrow-belong)"
: "url(#flow-dag-arrow)"; : "url(#flow-dag-arrow)";
paths.push( paths.push(
`<path class="${cls}" d="${d}" fill="none" marker-end="${marker}" />`, `<path class="${cls}" d="${d}" fill="none" marker-end="${marker}" data-from="${escAttr(fromId)}" data-to="${escAttr(toId)}" />`,
); );
} }
} }
@@ -3807,10 +3995,210 @@ function paintFlowDagEdges(dag) {
<marker id="flow-dag-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> <marker id="flow-dag-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead" /> <path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead" />
</marker> </marker>
<marker id="flow-dag-arrow-in" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead flow-dag-arrowhead--in" />
</marker>
<marker id="flow-dag-arrow-out" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead flow-dag-arrowhead--out" />
</marker>
<marker id="flow-dag-arrow-belong" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> <marker id="flow-dag-arrow-belong" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead flow-dag-arrowhead--belong" /> <path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead flow-dag-arrowhead--belong" />
</marker> </marker>
</defs>${paths.join("")}`; </defs>${paths.join("")}`;
const traceId = dag.getAttribute("data-flow-trace");
if (traceId) {
const node = nodes.find((el) => el.getAttribute("data-flow-id") === traceId);
if (node) applyFlowDagTrace(dag, node);
else clearFlowDagTrace(dag);
}
}
function flowHintItemsHtml(items, emptyText) {
if (!items.length) {
return `<p class="flow-dag-hint-empty">${esc(emptyText)}</p>`;
}
return `<ul class="flow-dag-hint-list">${items
.map(
(item) =>
`<li><span class="flow-dag-hint-item-name">${esc(item.name)}</span><span class="flow-dag-hint-item-run">${esc(item.run)}</span></li>`,
)
.join("")}</ul>`;
}
function placeFlowDagHint(dag, node) {
const hint = dag.querySelector(".flow-dag-hint");
if (!hint || hint.hidden) return;
const dagRect = dag.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const hintW = hint.offsetWidth;
const hintH = hint.offsetHeight;
const pad = 8;
let left = nodeRect.right - dagRect.left + dag.scrollLeft + 12;
let top = nodeRect.top - dagRect.top + dag.scrollTop;
const maxLeft = Math.max(pad, dag.scrollWidth - hintW - pad);
if (left > maxLeft) {
left = nodeRect.left - dagRect.left + dag.scrollLeft - hintW - 12;
}
left = Math.min(maxLeft, Math.max(pad, left));
const maxTop = Math.max(pad, dag.scrollHeight - hintH - pad);
if (top > maxTop) top = maxTop;
if (top < pad) top = pad;
hint.style.left = `${left}px`;
hint.style.top = `${top}px`;
}
function applyFlowDagTrace(dag, node) {
const findNode = flowDagNodeIndex(dag);
const nodeId = node.getAttribute("data-flow-id") || "";
const nodeName = node.getAttribute("data-flow-name") || "";
const refs = new Set([nodeId, nodeName].filter(Boolean));
const depRefs = parseFlowJsonList(node.getAttribute("data-flow-deps"));
const blocked = new Set(parseFlowJsonList(node.getAttribute("data-flow-blocked")));
const relatedIds = new Set([nodeId]);
dag.querySelectorAll(".flow-dag-edge").forEach((path) => {
const fromId = path.getAttribute("data-from") || "";
const toId = path.getAttribute("data-to") || "";
const isIn = toId === nodeId;
const isOut = fromId === nodeId;
path.classList.toggle("is-trace-in", isIn);
path.classList.toggle("is-trace-out", isOut && !isIn);
if (isIn) {
path.setAttribute("marker-end", "url(#flow-dag-arrow-in)");
relatedIds.add(fromId);
} else if (isOut) {
path.setAttribute("marker-end", "url(#flow-dag-arrow-out)");
relatedIds.add(toId);
} else {
path.setAttribute(
"marker-end",
path.classList.contains("flow-dag-edge--belong")
? "url(#flow-dag-arrow-belong)"
: "url(#flow-dag-arrow)",
);
}
});
dag.querySelectorAll("[data-flow-id]").forEach((el) => {
const id = el.getAttribute("data-flow-id") || "";
el.classList.toggle("is-trace-root", el === node);
el.classList.toggle("is-trace-related", el !== node && relatedIds.has(id));
});
const deps = depRefs
.map((ref) => {
const el = findNode(ref);
if (el && el.getAttribute("data-flow-from") === nodeId) return null;
return {
name: el ? flowNodeHintName(el) : ref,
run: el
? flowNodeRunLabel(el)
: blocked.has(ref)
? "未齐"
: "",
};
})
.filter(Boolean);
const downstream = [...dag.querySelectorAll("[data-flow-id]")]
.filter((el) => {
if (el === node) return false;
if (el.getAttribute("data-flow-from") === nodeId) return false;
return parseFlowJsonList(el.getAttribute("data-flow-deps")).some((ref) =>
refs.has(ref),
);
})
.map((el) => ({
name: flowNodeHintName(el),
run: flowNodeRunLabel(el),
}));
const hint = dag.querySelector(".flow-dag-hint");
if (hint) {
hint.innerHTML = `<p class="flow-dag-hint-kicker">依赖关系</p>
<p class="flow-dag-hint-name">${esc(flowNodeHintName(node))}</p>
<div class="flow-dag-hint-sec">
<div class="flow-dag-hint-label">依赖</div>
${flowHintItemsHtml(deps, "无前置")}
</div>
<div class="flow-dag-hint-sec">
<div class="flow-dag-hint-label">下游</div>
${flowHintItemsHtml(downstream, "无下游")}
</div>`;
hint.hidden = false;
placeFlowDagHint(dag, node);
}
dag.classList.add("is-tracing");
dag.setAttribute("data-flow-trace", nodeId);
}
function clearFlowDagTrace(dag) {
if (!dag) return;
dag.classList.remove("is-tracing");
dag.removeAttribute("data-flow-trace");
dag.querySelectorAll(".flow-dag-edge").forEach((path) => {
path.classList.remove("is-trace-in", "is-trace-out");
path.setAttribute(
"marker-end",
path.classList.contains("flow-dag-edge--belong")
? "url(#flow-dag-arrow-belong)"
: "url(#flow-dag-arrow)",
);
});
dag.querySelectorAll("[data-flow-id]").forEach((el) => {
el.classList.remove("is-trace-root", "is-trace-related");
});
const hint = dag.querySelector(".flow-dag-hint");
if (hint) {
hint.hidden = true;
hint.replaceChildren();
}
}
function setFlowDagTrace(dag, node) {
if (!dag || !node) return;
if (dag.getAttribute("data-flow-trace") === node.getAttribute("data-flow-id")) {
return;
}
applyFlowDagTrace(dag, node);
}
function wireFlowDagTrace(dag) {
dag.addEventListener("pointerover", (e) => {
const node = e.target.closest("[data-flow-id]");
if (node && dag.contains(node)) setFlowDagTrace(dag, node);
});
dag.addEventListener("pointerleave", () => {
const focused = dag.querySelector("[data-flow-id]:focus");
if (focused) {
applyFlowDagTrace(dag, focused);
return;
}
clearFlowDagTrace(dag);
});
dag.addEventListener("focusin", (e) => {
const node = e.target.closest("[data-flow-id]");
if (node && dag.contains(node)) setFlowDagTrace(dag, node);
});
dag.addEventListener("focusout", (e) => {
const next = e.relatedTarget;
if (next && dag.contains(next) && next.closest("[data-flow-id]")) return;
if (dag.matches(":hover")) return;
clearFlowDagTrace(dag);
});
dag.addEventListener(
"scroll",
() => {
const id = dag.getAttribute("data-flow-trace");
if (!id) return;
const node = [...dag.querySelectorAll("[data-flow-id]")].find(
(el) => el.getAttribute("data-flow-id") === id,
);
if (node) placeFlowDagHint(dag, node);
},
{ passive: true },
);
} }
function wireCreationFlowGraph(root, handlers) { function wireCreationFlowGraph(root, handlers) {
@@ -3825,16 +4213,40 @@ function wireCreationFlowGraph(root, handlers) {
}); });
}); });
root.querySelectorAll("[data-flow-done]").forEach((el) => { root.querySelectorAll("[data-flow-done]").forEach((el) => {
const stepId = el.getAttribute("data-flow-done");
const canReenter = el.getAttribute("data-can-reenter") === "1";
const openMenu = (e) => { const openMenu = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
showFlowNodeMenu(el, e.clientX, e.clientY, handlers); showFlowNodeMenu(el, e.clientX, e.clientY, handlers);
}; };
el.addEventListener("click", openMenu); el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (e.target.closest("[data-flow-menu-trigger]")) {
openMenu(e);
return;
}
if (canReenter && stepId) {
handlers.onReenterCreationStep?.(stepId);
return;
}
openMenu(e);
});
el.addEventListener("contextmenu", openMenu); el.addEventListener("contextmenu", openMenu);
el.addEventListener("keydown", (e) => { el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " " && e.key !== "ContextMenu") return; if (e.key === "ContextMenu") {
e.preventDefault();
const rect = el.getBoundingClientRect();
showFlowNodeMenu(el, rect.left, rect.bottom + 4, handlers);
return;
}
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault(); e.preventDefault();
if (canReenter && stepId) {
handlers.onReenterCreationStep?.(stepId);
return;
}
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
showFlowNodeMenu(el, rect.left, rect.bottom + 4, handlers); showFlowNodeMenu(el, rect.left, rect.bottom + 4, handlers);
}); });
@@ -3863,6 +4275,7 @@ function wireCreationFlowGraph(root, handlers) {
dag.querySelectorAll(".flow-family").forEach((n) => ro.observe(n)); dag.querySelectorAll(".flow-family").forEach((n) => ro.observe(n));
} }
window.addEventListener("resize", paint, { passive: true }); window.addEventListener("resize", paint, { passive: true });
wireFlowDagTrace(dag);
}); });
} }
@@ -3995,6 +4408,7 @@ function renderReviewWorkspace(review, opts = {}) {
return ` return `
<section class="workspace-review" data-review="1" data-review-kind="${esc(copy.kind)}" data-message-id="${esc(contextId)}" data-can-edit="0" data-has-context="${hasContext}"> <section class="workspace-review" data-review="1" data-review-kind="${esc(copy.kind)}" data-message-id="${esc(contextId)}" data-can-edit="0" data-has-context="${hasContext}">
<header class="workspace-review-head workspace-review-head--slim"> <header class="workspace-review-head workspace-review-head--slim">
${opts.leaveStepHtml || ""}
<div class="workspace-review-titles"> <div class="workspace-review-titles">
<span class="workspace-review-kicker">${esc(copy.kicker)}</span> <span class="workspace-review-kicker">${esc(copy.kicker)}</span>
<h2 class="workspace-review-title">${esc(label)}</h2> <h2 class="workspace-review-title">${esc(label)}</h2>
@@ -4512,6 +4926,7 @@ function renderSpeakWorkspace(view) {
: ""; : "";
return `<section class="workspace-intent"> return `<section class="workspace-intent">
<div class="workspace-intent-head"> <div class="workspace-intent-head">
${leaveStepControlHtml(view)}
${ ${
flowInteractive flowInteractive
? workspaceViewSwitchHtml("flow") ? workspaceViewSwitchHtml("flow")
@@ -4519,7 +4934,6 @@ function renderSpeakWorkspace(view) {
canOfferSaveProduct(view) ? "落档" : "说话" canOfferSaveProduct(view) ? "落档" : "说话"
}</span>` }</span>`
} }
${leaveStepControlHtml(view)}
</div> </div>
<h2 class="workspace-intent-title">${esc(title)}</h2> <h2 class="workspace-intent-title">${esc(title)}</h2>
<p class="workspace-intent-hint">${esc(hint)}</p> <p class="workspace-intent-hint">${esc(hint)}</p>
@@ -4534,9 +4948,9 @@ function renderSpeakWorkspace(view) {
function renderAnswerWorkspaceShell(view) { function renderAnswerWorkspaceShell(view) {
return `<section class="workspace-answer" id="workspace-answer-slot"> return `<section class="workspace-answer" id="workspace-answer-slot">
<header class="workspace-answer-head workspace-answer-head--slim"> <header class="workspace-answer-head workspace-answer-head--slim">
${leaveStepControlHtml(view)}
<span class="workspace-surface-kicker">答题</span> <span class="workspace-surface-kicker">答题</span>
<p class="workspace-answer-hint">点字母选中,文案可改;点卡片展开,点顶条收起。</p> <p class="workspace-answer-hint">点字母选中,文案可改;点卡片展开,点顶条收起。</p>
${leaveStepControlHtml(view)}
</header> </header>
</section>`; </section>`;
} }
@@ -4602,10 +5016,12 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) {
renderReviewWorkspace(view.reviewArtifact, { renderReviewWorkspace(view.reviewArtifact, {
hideAskSidecar: opts.hideAskSidecar === true, hideAskSidecar: opts.hideAskSidecar === true,
sourceMessage: resolveReviewSourceMessage(view), sourceMessage: resolveReviewSourceMessage(view),
leaveStepHtml: leaveStepControlHtml(view),
}) + askSlot; }) + askSlot;
wireMessageFeedActions(stage, handlers); wireMessageFeedActions(stage, handlers);
wireMessageContextMenu(stage, handlers); wireMessageContextMenu(stage, handlers);
wireCreationFlowGraph(stage, handlers); wireCreationFlowGraph(stage, handlers);
wireLeaveStepControl(stage, handlers);
wireOpeningPicker(); wireOpeningPicker();
if (opts.mountAskCard) { if (opts.mountAskCard) {
const askHost = document.getElementById("workspace-review-ask"); const askHost = document.getElementById("workspace-review-ask");
@@ -4696,13 +5112,13 @@ export function renderMessageFeed(view, loading, handlers = {}) {
stage.innerHTML = ` stage.innerHTML = `
<div class="workspace-pending" id="msg-live-pending"> <div class="workspace-pending" id="msg-live-pending">
<header class="workspace-pending-head"> <header class="workspace-pending-head">
${leaveStepControlHtml(view)}
<span class="msg-tag">${esc(label)}</span> <span class="msg-tag">${esc(label)}</span>
<span class="msg-live-indicator">流式输出中</span> <span class="msg-live-indicator">流式输出中</span>
${renderRetryRunButton()}
</header> </header>
<div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div> <div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div>
</div>`; </div>`;
wireRetryRun(stage, handlers); wireLeaveStepControl(stage, handlers);
return; return;
} }
+38 -22
View File
@@ -546,7 +546,7 @@ function resolveComposer(view, loading) {
function composerModeChip(spec) { function composerModeChip(spec) {
const id = spec.task?.id; const id = spec.task?.id;
if (id !== "speak" && id !== "answer" && id !== "review") return ""; if (id !== "speak" && id !== "answer" && id !== "review" && id !== "busy") return "";
const label = spec.task.label || ""; const label = spec.task.label || "";
const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — "); const tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — ");
const tone = spec.acceptAction?.tone ? ` data-tone="${esc(spec.acceptAction.tone)}"` : ""; const tone = spec.acceptAction?.tone ? ` data-tone="${esc(spec.acceptAction.tone)}"` : "";
@@ -586,13 +586,6 @@ function syncThemeMenuUi() {
}); });
} }
function leaveStepButtonHtml(spec) {
if (!spec.leaveStep) return "";
return `<button type="button" class="btn" data-act="leave_step" title="${esc(
spec.leaveStep.title || "先不写这一步,回到节点选择",
)}">${esc(spec.leaveStep.label)}</button>`;
}
function stashComposerDraftFromDom() { function stashComposerDraftFromDom() {
if (skipComposerStash) { if (skipComposerStash) {
skipComposerStash = false; skipComposerStash = false;
@@ -650,20 +643,22 @@ function renderComposer(view, loading) {
host._enterHandler = null; host._enterHandler = null;
} }
const spec = resolveComposer(view, loading); const spec = resolveComposer(view, loading);
const leaveAct = view.actions?.find((a) => a.type === "leave_step");
if (leaveAct && spec.mode !== "waiting" && spec.mode !== "idle") {
spec.leaveStep = {
label: leaveAct.label || "返回节点",
title: "先不写这一步,回到节点选择",
};
}
if (spec.mode === "waiting") { if (spec.mode === "waiting") {
root.innerHTML = `<div class="composer-waiting"> const shell = composerInputShell(
<span>${esc(spec.text)}</span> { ...spec, task: { ...(spec.task || {}), id: "busy", label: spec.task?.label || "生成中" } },
<button type="button" class="btn" data-act="retry_run" title="停止当前生成并重试">停止并重试</button> {
</div>`; textareaHtml: `<textarea id="composer-input" rows="1" placeholder="生成中…可提前停止或重roll"></textarea>`,
trailing: `<button type="button" class="btn composer-btn-icon" data-act="abort_run" title="提前停止" aria-label="提前停止">□</button>
<button type="button" class="btn composer-btn-icon" data-act="retry_run" title="重roll" aria-label="重roll">🔄</button>`,
},
);
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
root.querySelector("#composer-form")?.addEventListener("submit", (e) => e.preventDefault());
root.querySelector("[data-act=abort_run]")?.addEventListener("click", () => abortRun());
root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun()); root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun());
applyPendingComposerDraft();
const input = $("composer-input");
if (input) autosizeComposerInput(input);
return; return;
} }
if (spec.mode === "idle") { if (spec.mode === "idle") {
@@ -878,7 +873,7 @@ function renderComposer(view, loading) {
: spec.placeholder; : spec.placeholder;
const shell = composerInputShell(spec, { const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`, textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`,
trailing: `${leaveStepButtonHtml(spec)}<button type="button" class="btn composer-btn${ trailing: `<button type="button" class="btn composer-btn${
sendIsPrimary ? " btn-primary" : "" sendIsPrimary ? " btn-primary" : ""
}" data-act="revise" title="${esc( }" data-act="revise" title="${esc(
acceptOnEmpty acceptOnEmpty
@@ -889,7 +884,6 @@ function renderComposer(view, loading) {
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`; root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
wireComposerForm({ acceptOnEmpty }); wireComposerForm({ acceptOnEmpty });
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
root.querySelector("[data-act=leave_step]")?.addEventListener("click", () => runAction("leave_step"));
applyPendingComposerDraft(); applyPendingComposerDraft();
} }
@@ -1953,6 +1947,23 @@ async function retryRun() {
} }
} }
async function abortRun() {
if (!sessionId) return;
const seq = beginUiRequest();
try {
renderSession(lastView, true);
const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, {
method: "POST",
body: JSON.stringify({ action: "abort_run" }),
});
if (isStaleUiRequest(seq)) return;
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
function startLivePoll() { function startLivePoll() {
stopLivePoll(); stopLivePoll();
if (!sessionId) return; if (!sessionId) return;
@@ -2009,6 +2020,11 @@ function renderSession(view, loading = false) {
onReenterCreationStep: (stepId) => onReenterCreationStep: (stepId) =>
runAction("pick_step", { stepId, reenter: true }), runAction("pick_step", { stepId, reenter: true }),
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name), onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
onDeleteCreationStep: (stepId, name) => {
const label = name ? `${name}` : stepId;
if (!confirm(`删除「${label}」这条产物?会从图上拿掉,不可恢复。`)) return;
void runAction("delete_step", { stepId });
},
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }), onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
onLeaveCreationStep: () => runAction("leave_step"), onLeaveCreationStep: () => runAction("leave_step"),
onEnterPlay: () => enterPlayNow(), onEnterPlay: () => enterPlayNow(),
+2 -1
View File
@@ -233,7 +233,8 @@
<menu id="flow-node-menu" class="context-menu" hidden> <menu id="flow-node-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button> <button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button>
<button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">重新进入修改</button> <button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">进入继续修改</button>
<button type="button" class="context-menu-item context-menu-danger context-menu-separator-before" id="flow-menu-delete" data-flow-menu-action="delete">删除产物</button>
</menu> </menu>
<dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title"> <dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title">
+168 -21
View File
@@ -479,21 +479,26 @@ body.design-workspace.coord-rail-collapsed .main-body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 10px; margin-bottom: 10px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.workspace-review-head .workspace-leave-step {
flex-shrink: 0;
margin-right: 2px;
}
.workspace-review-titles { .workspace-review-titles {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0; min-width: 0;
flex: 1 1 auto;
} }
.workspace-review-nav { .workspace-review-nav {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
} }
.workspace-review-nav .msg-variant-nav { .workspace-review-nav .msg-variant-nav {
margin: 0; margin: 0;
@@ -860,6 +865,9 @@ body.design-workspace .workspace-pending {
margin-bottom: 6px; margin-bottom: 6px;
font-size: 12px; font-size: 12px;
} }
.workspace-pending-head .workspace-leave-step {
margin-right: 4px;
}
.workspace-pending-head .msg-action { .workspace-pending-head .msg-action {
margin-left: auto; margin-left: auto;
} }
@@ -948,7 +956,7 @@ body.design-workspace .review-worker-set {
.workspace-intent-head { .workspace-intent-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: flex-start;
gap: 12px; gap: 12px;
margin-bottom: 8px; margin-bottom: 8px;
max-width: 46rem; max-width: 46rem;
@@ -979,7 +987,7 @@ body.design-workspace .review-worker-set {
outline-offset: 2px; outline-offset: 2px;
} }
.workspace-answer-head--slim .workspace-leave-step { .workspace-answer-head--slim .workspace-leave-step {
margin-left: auto; margin-right: 4px;
} }
.workspace-intent, .workspace-intent,
.workspace-answer { .workspace-answer {
@@ -1213,7 +1221,7 @@ body.design-workspace .flow-status {
body.design-workspace .flow-dag { body.design-workspace .flow-dag {
position: relative; position: relative;
overflow: auto; overflow: auto;
padding: 4px 2px 8px; padding: 8px 8px 16px;
} }
body.design-workspace .flow-dag-edges { body.design-workspace .flow-dag-edges {
position: absolute; position: absolute;
@@ -1225,23 +1233,41 @@ body.design-workspace .flow-dag-edges {
overflow: visible; overflow: visible;
} }
body.design-workspace .flow-dag-edge { body.design-workspace .flow-dag-edge {
stroke: color-mix(in srgb, var(--border) 80%, var(--muted)); stroke: color-mix(in srgb, var(--text) 32%, var(--border));
stroke-width: 1.5; stroke-width: 1.5;
opacity: 0.85; opacity: 0.55;
} stroke-linecap: round;
body.design-workspace .flow-dag-edge.is-lit {
stroke: color-mix(in srgb, var(--accent) 55%, var(--border));
opacity: 1;
} }
body.design-workspace .flow-dag-arrowhead { body.design-workspace .flow-dag-arrowhead {
fill: color-mix(in srgb, var(--accent) 50%, var(--border)); fill: color-mix(in srgb, var(--text) 38%, var(--border));
}
body.design-workspace .flow-dag.is-tracing .flow-dag-edge {
opacity: 0.12;
}
body.design-workspace .flow-dag.is-tracing .flow-dag-edge.is-trace-in,
body.design-workspace .flow-dag.is-tracing .flow-dag-edge.is-trace-out {
opacity: 1;
}
body.design-workspace .flow-dag-edge.is-trace-in {
stroke: var(--accent);
stroke-width: 2.35;
}
body.design-workspace .flow-dag-edge.is-trace-out {
stroke: color-mix(in srgb, var(--text) 72%, var(--accent));
stroke-width: 2.1;
}
body.design-workspace .flow-dag-arrowhead--in {
fill: var(--accent);
}
body.design-workspace .flow-dag-arrowhead--out {
fill: color-mix(in srgb, var(--text) 72%, var(--accent));
} }
body.design-workspace .flow-dag-rows { body.design-workspace .flow-dag-rows {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28px; gap: 44px;
min-width: 0; min-width: 0;
} }
body.design-workspace .flow-dag-row { body.design-workspace .flow-dag-row {
@@ -1262,8 +1288,96 @@ body.design-workspace .flow-dag-row-nodes {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 24px 104px;
min-width: 0; min-width: 0;
padding: 0 28px;
}
body.design-workspace .flow-dag.is-tracing .flow-node {
opacity: 0.4;
}
body.design-workspace .flow-dag.is-tracing .flow-node.is-trace-root,
body.design-workspace .flow-dag.is-tracing .flow-node.is-trace-related,
body.design-workspace .flow-dag.is-tracing .flow-node.is-locked.is-trace-root,
body.design-workspace .flow-dag.is-tracing .flow-node.is-locked.is-trace-related {
opacity: 1;
}
body.design-workspace .flow-dag.is-tracing .flow-node.is-trace-root {
position: relative;
z-index: 2;
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
body.design-workspace .flow-dag-hint {
position: absolute;
z-index: 4;
width: max-content;
max-width: min(240px, calc(100% - 24px));
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 8px 22px color-mix(in srgb, var(--text) 12%, transparent);
pointer-events: none;
color: var(--text);
}
body.design-workspace .flow-dag-hint[hidden] {
display: none !important;
}
body.design-workspace .flow-dag-hint-kicker {
margin: 0 0 2px;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.06em;
color: var(--muted);
}
body.design-workspace .flow-dag-hint-name {
margin: 0 0 8px;
font-size: 13px;
font-weight: 650;
line-height: 1.35;
}
body.design-workspace .flow-dag-hint-sec + .flow-dag-hint-sec {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
body.design-workspace .flow-dag-hint-label {
font-size: 11px;
font-weight: 650;
color: var(--muted);
margin-bottom: 4px;
}
body.design-workspace .flow-dag-hint-empty {
margin: 0;
font-size: 12px;
color: var(--muted);
}
body.design-workspace .flow-dag-hint-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
body.design-workspace .flow-dag-hint-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 12px;
line-height: 1.35;
}
body.design-workspace .flow-dag-hint-item-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.design-workspace .flow-dag-hint-item-run {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
} }
body.design-workspace .flow-node { body.design-workspace .flow-node {
display: flex; display: flex;
@@ -1321,10 +1435,13 @@ body.design-workspace .flow-node-menu-btn {
line-height: 1; line-height: 1;
color: var(--muted); color: var(--muted);
opacity: 0.55; opacity: 0.55;
pointer-events: none; pointer-events: auto;
cursor: context-menu;
} }
body.design-workspace .flow-node.is-done-menu:hover .flow-node-menu-btn, body.design-workspace .flow-node.is-done-menu:hover .flow-node-menu-btn,
body.design-workspace .flow-node.is-done-menu:focus-visible .flow-node-menu-btn { body.design-workspace .flow-node.is-done-menu:focus-visible .flow-node-menu-btn,
body.design-workspace .flow-node.is-selectable:hover .flow-node-menu-btn,
body.design-workspace .flow-node.is-selectable:focus-visible .flow-node-menu-btn {
opacity: 1; opacity: 1;
} }
.step-artifact-body { .step-artifact-body {
@@ -1349,14 +1466,12 @@ body.design-workspace .flow-node.is-selectable {
0 4px 12px color-mix(in srgb, var(--accent) 12%, transparent); 0 4px 12px color-mix(in srgb, var(--accent) 12%, transparent);
} }
body.design-workspace .flow-node.is-selectable:hover { body.design-workspace .flow-node.is-selectable:hover {
transform: translateY(-2px);
background: color-mix(in srgb, var(--accent) 10%, var(--surface)); background: color-mix(in srgb, var(--accent) 10%, var(--surface));
box-shadow: box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent); 0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent);
} }
body.design-workspace .flow-node.is-selectable:active { body.design-workspace .flow-node.is-selectable:active {
transform: translateY(0) scale(0.98);
box-shadow: box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
0 2px 6px color-mix(in srgb, var(--accent) 12%, transparent); 0 2px 6px color-mix(in srgb, var(--accent) 12%, transparent);
@@ -1465,6 +1580,7 @@ body.design-workspace .flow-spawn-meta {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px;
} }
body.design-workspace .flow-spawn-meta .flow-action { body.design-workspace .flow-spawn-meta .flow-action {
min-height: 24px; min-height: 24px;
@@ -1495,7 +1611,11 @@ body.design-workspace .flow-dag-edge--belong {
stroke-width: 1.25; stroke-width: 1.25;
} }
body.design-workspace .flow-dag-arrowhead--belong { body.design-workspace .flow-dag-arrowhead--belong {
fill: color-mix(in srgb, var(--accent) 38%, var(--border)); fill: color-mix(in srgb, var(--text) 28%, var(--border));
}
body.design-workspace .flow-dag.is-tracing .flow-dag-edge--belong.is-trace-in,
body.design-workspace .flow-dag.is-tracing .flow-dag-edge--belong.is-trace-out {
stroke-dasharray: 4 4;
} }
body.design-workspace .flow-suggestion-inline { body.design-workspace .flow-suggestion-inline {
font-size: 12px; font-size: 12px;
@@ -1604,6 +1724,8 @@ body.design-workspace .flow-spawn.is-blocked {
body.design-workspace .flow-dag-row-nodes { body.design-workspace .flow-dag-row-nodes {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 12px;
padding: 0;
} }
body.design-workspace .flow-node, body.design-workspace .flow-node,
body.design-workspace .flow-family { body.design-workspace .flow-family {
@@ -3559,8 +3681,21 @@ body.design-workspace .af-mosaic {
color: var(--muted); color: var(--muted);
font-weight: 600; font-weight: 600;
} }
.skill-pool-desc { .skill-pool-card.is-direction {
margin: 6px 0 0; grid-column: 1 / -1;
}
.skill-pool-dirs {
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.skill-pool-dir dt {
font-size: 12px;
font-weight: 600;
}
.skill-pool-dir dd {
margin: 2px 0 0;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
line-height: 1.4; line-height: 1.4;
@@ -4821,7 +4956,7 @@ body.design-workspace .af-mosaic {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 10px;
padding-bottom: 2px; padding-bottom: 2px;
} }
.composer-mode-chip { .composer-mode-chip {
@@ -4861,6 +4996,10 @@ body.design-workspace .af-mosaic {
.composer-input-shell[data-task="review"][data-tone="plan"] .composer-mode-chip { .composer-input-shell[data-task="review"][data-tone="plan"] .composer-mode-chip {
color: var(--accent); color: var(--accent);
} }
.composer-mode-chip[data-task="busy"],
.composer-input-shell[data-task="busy"] .composer-mode-chip {
color: var(--muted);
}
.composer-input-shell textarea { .composer-input-shell textarea {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -4917,6 +5056,7 @@ body.design-workspace .af-mosaic {
.composer-shell-actions > .btn-primary, .composer-shell-actions > .btn-primary,
.composer-shell-actions > .composer-btn, .composer-shell-actions > .composer-btn,
.composer-shell-actions > .composer-btn-accept, .composer-shell-actions > .composer-btn-accept,
.composer-shell-actions > .composer-btn-icon,
.composer-form > .btn, .composer-form > .btn,
.composer-form > .btn-primary, .composer-form > .btn-primary,
.composer-form > .composer-btn, .composer-form > .composer-btn,
@@ -4933,6 +5073,13 @@ body.design-workspace .af-mosaic {
white-space: nowrap; white-space: nowrap;
font-size: 13px; font-size: 13px;
} }
.composer-btn-icon {
width: calc(var(--composer-control-h) - 8px);
min-width: calc(var(--composer-control-h) - 8px);
padding: 0;
font-size: 16px;
line-height: 1;
}
/* 验收态:主按钮承接原「批阅」语义 */ /* 验收态:主按钮承接原「批阅」语义 */
.composer-btn-review { .composer-btn-review {
background: #15803d; background: #15803d;