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:
@@ -58,7 +58,7 @@
|
||||
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
|
||||
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
|
||||
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
|
||||
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段 + **数量**与**一次生成**(单条|同批,同批须钉记录间关系));自评=必要性/属性妥当/格式准确。
|
||||
生成规则正文:必要性判断 + `rules[]`(设计判断 + 生成与描写含**配方/表现** + **分级表现** + **单层**产物格式 + 可选池:**方向池**以方向概括为主、例子极少,**元素池**仅封闭集合,旁观抽样只抽元素池 + **数量**与**一次生成**);自评=必要性/属性妥当/材料得当/格式准确。
|
||||
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
|
||||
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
|
||||
回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。
|
||||
@@ -144,7 +144,7 @@ API:`POST /api/sessions/:id/context-order`(`move` / `set_projection` / `set_
|
||||
|--------|----------|
|
||||
| `context-fragment.v1` 外壳 | brief / 挂载 / **自评十分制(x/10)** / 追问 |
|
||||
| 美学纲领与交互范式 | mosaic(设定逻辑 / 交互范式 / 美学纲领) |
|
||||
| 生成规则 | 必要性 + 规则卡(方法、单层字段表、池) |
|
||||
| 生成规则 | 必要性 + 规则卡(配方/表现、分级表象、单层字段表、方向池/元素池) |
|
||||
| 舞台骨架 | 尺度 / 社会 / 状况 / 关键区实体卡 |
|
||||
| 实现机制 | 支撑点卡 + 关系 + 覆盖检验 |
|
||||
| 其它技能正文 | 通用结构化(KV);**应补专用卡** |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| 块 id | 必填 | 用途 |
|
||||
|-------|------|------|
|
||||
| `meta` | 建议 | YAML:name / id / artifact / declaration / when / when_not / boundary |
|
||||
| `opening` | 可选 | **默认问题**正文。开局:配方 `steps` 已预置进「设计.创作流程」,用户首句即坐在 DAG 第一步;若「用户.需求」已有内容,程序**跳过**再抛 opening,直接进本步 LLM |
|
||||
| `opening` | 可选 | **默认问题**正文。点进节点时由程序发给用户(不经本步 LLM),用户先答一轮再产出。整块可省略 = 本步直接 LLM |
|
||||
| `task` | 是 | 本步任务与验收边界 |
|
||||
| `principles` | 建议 | 原则 |
|
||||
| `probe` | 建议 | 追问策略 |
|
||||
@@ -173,7 +173,7 @@ recipes/world-simulator|expand-assistant/recipe.yaml
|
||||
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
|
||||
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
|
||||
| 舞台骨架 | `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` |
|
||||
| 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 |
|
||||
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
|
||||
|
||||
@@ -57,8 +57,7 @@ feeds: design_only
|
||||
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
|
||||
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
|
||||
|
||||
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。
|
||||
开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。
|
||||
若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。黑板「用户.需求」里已有的句子当作补充材料采纳,不代替本步引导作答。
|
||||
```
|
||||
|
||||
## principles
|
||||
@@ -79,7 +78,7 @@ feeds: design_only
|
||||
```probe
|
||||
本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。
|
||||
|
||||
用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉;开场若已跳过,把「用户.需求」首句当答复。已答清的不重问。
|
||||
用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉。已答清的不重问。
|
||||
|
||||
缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ modules:
|
||||
repeatable: true
|
||||
declaration: >-
|
||||
要精准格式,或本局原创导致知识中无可参考对象、或难以在无指导情况下生成足够可信内容且故事里还要生成时:写出该对象族怎么生成;
|
||||
不填最终实例;可按对象反复
|
||||
创意字段用方向池,不堆枚举词;不填最终实例;可按对象反复
|
||||
artifact: 设计.生成规则
|
||||
params:
|
||||
- key: target
|
||||
|
||||
@@ -19,7 +19,7 @@ when_not: |
|
||||
规则是 `runtime_only` → 不排,游玩期再生成。
|
||||
只要规则不要预写实例 → 不排。
|
||||
boundary: |
|
||||
本技能只做一件事:按指定规则生成 records。规则说怎么写、什么格式、如何用池,就照做。
|
||||
本技能只做一件事:按指定规则生成 records。规则说怎么写、什么格式、如何用池、填到多厚,就照做。
|
||||
生成规则:合同制定方——本步不重论证、不修改。
|
||||
feeds: gm
|
||||
```
|
||||
@@ -49,8 +49,14 @@ feeds: gm
|
||||
- `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
|
||||
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
|
||||
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
|
||||
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
|
||||
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。
|
||||
3. 完全按规则的「生成与描写」+「产物格式」+「池」+「分级表现」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
|
||||
池的用法以规则 `池型` 为准:
|
||||
- **方向**:以方向为主。粒度是词条/方向时,字段值可以就是方向名。粒度是句子时,按该方向写开(方向名可作标签,后面仍要达到地板)。方向说明里的专名不是选项库,不要按专名点菜。
|
||||
- **元素**:从 `条目` 里抽取或必选,写入绑定字段。
|
||||
有 **配方**:按配方从各组件取方向,拼进目标字段那一格。
|
||||
有 **分级表现**:实例只填档位短值;不要把整段表象复制进 record,除非 schema 单独设了表象字段。叙事时按规则展开。
|
||||
无池的描写字段按 `粒度`/`地板`/`表现`/`工作流` 写到够厚。
|
||||
4. 按规则自检(含地板:句子级不得只用一个词交差);不合格内部重做。已有产物则追加批次(除非用户要求替换)。
|
||||
5. 输出 JSON;追问通常 `[]`。
|
||||
|
||||
summary:`具体实例 · {对象} · {n}条`
|
||||
@@ -59,10 +65,11 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
## principles
|
||||
|
||||
```principles
|
||||
1. 只按规则来:方法、格式、池、数量、一次生成、约束——规则有什么用什么;没有的不发明。
|
||||
1. 只按规则来:方法、配方、表现、分级表现、格式、池型、数量、一次生成、约束、地板——规则有什么用什么;没有的不发明。
|
||||
2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。
|
||||
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
|
||||
4. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
|
||||
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。方向以方向为主;说明里的专名不是选项。
|
||||
4. 句子级描写必须达到该字段 `地板`;词条/档位/数字可以短。句子级不要只丢一个词交差;方向名可以当选项或标签。
|
||||
5. 要改合同 → 回「生成规则」,本步不加字段、不改枚举、不改池型。
|
||||
```
|
||||
|
||||
## probe
|
||||
@@ -98,7 +105,7 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
{
|
||||
"名": "合规模",
|
||||
"分数": 8,
|
||||
"说明": "是否严格按该规则的方法、单层格式、池与约束生成"
|
||||
"说明": "是否按方法/配方/表现/单层格式/池型(方向为主,元素=抽取)/分级表现/地板生成;句子级是否达到粒度"
|
||||
}
|
||||
],
|
||||
"薄弱点": "一句话;无则空字符串"
|
||||
@@ -123,7 +130,7 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
```checklist
|
||||
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
|
||||
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组?
|
||||
- [ ] records 单层、键值符合产物格式与全部约束?
|
||||
- [ ] records 单层、键值符合产物格式与全部约束?句子级是否达到地板?有配方是否组装进目标字段?方向是否以方向为主(专名不当选项库)?
|
||||
- [ ] 自评为合规模?未改合同?
|
||||
```
|
||||
|
||||
@@ -132,6 +139,9 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
```examples
|
||||
好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。
|
||||
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。
|
||||
好:方向「冷焰」作标签,能力描述按该方向写开到可感知;或粒度=方向时字段值就是「冷焰」。
|
||||
好:阶层字段填「金丹」,表象留在规则的分级表现里,不把整段对照复制进每条 record。
|
||||
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
|
||||
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
|
||||
坏:句子级只填「阴郁」;或按方向说明里那个专名点菜交差。
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
|
||||
> 产物外壳:`docs/context-fragment-design.md`;范例:`aesthetics-interaction` / `mechanism`。
|
||||
> 正文高度自定义(描写文案、schema 字段、池条目随对象族而变);外壳与规则键必须严。
|
||||
> 正文高度自定义(描写文案、schema 字段、池随对象族而变);外壳与规则键必须严。
|
||||
|
||||
## meta
|
||||
|
||||
@@ -11,7 +11,8 @@ name: 生成规则
|
||||
id: generation-rules
|
||||
artifact: 设计.生成规则
|
||||
declaration: >
|
||||
要精准格式,或本局原创听了会雾且故事里还要生成时:写出该对象族怎么生成(描写/格式/可选池);不填最终实例;可按对象反复
|
||||
要精准格式,或本局原创听了会雾且故事里还要生成时:写出该对象族怎么生成(描写/格式/可选池);
|
||||
创意字段用方向池给创作范围,不堆枚举词;不填最终实例;可按对象反复
|
||||
when: |
|
||||
已有「美学纲领与交互范式」或等价整体感觉,且下面两条满足一条就排(可按对象族反复):
|
||||
a. 生成物必须有精准格式。例:武功典籍要有名字、效果、阶层。
|
||||
@@ -22,12 +23,12 @@ when_not: |
|
||||
只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。
|
||||
跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「叙事指南与故事推进」。
|
||||
boundary: |
|
||||
本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选多池(各绑一个顶层字段)。
|
||||
具体实例:按规则填最终 records;本步不写最终实例(池条目≠实例)。
|
||||
本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。
|
||||
具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。
|
||||
舞台骨架 / 实现机制:可引用,不重写百科、不重钉既成法则。
|
||||
变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。
|
||||
随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。
|
||||
旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);不依赖整份描写长文。
|
||||
旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。
|
||||
feeds: gm,auditor
|
||||
```
|
||||
|
||||
@@ -43,7 +44,7 @@ feeds: gm,auditor
|
||||
2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西?
|
||||
3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
|
||||
4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。
|
||||
5. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
|
||||
5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。
|
||||
|
||||
想到什么写什么;完整规则在正式产物里钉。
|
||||
```
|
||||
@@ -53,11 +54,10 @@ feeds: gm,auditor
|
||||
```task
|
||||
你正在执行「生成规则」。产物必须是 **context-fragment.v1** JSON,写入「设计.生成规则」。
|
||||
|
||||
本步写出的最终物是**规则**,不是实例。一条规则必须说清两件事,并可附第三件:
|
||||
1. **生成与描写**:应该怎么写、怎么生成(步骤、依据、语气/切面、约束)。
|
||||
2. **产物格式**:生成结果必须遵循的可校验格式(schema)。
|
||||
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举;
|
||||
一条规则可多个池;无则 `池: []`。
|
||||
本步写出的最终物是**规则**,不是实例。规则是创造这类东西的方法:空白表格 + 怎么填厚 + 必要时给方向。一条规则必须说清两件事,并可附第三件:
|
||||
1. **生成与描写**:按什么顺序写、多方向如何拼进一格、每格填到多厚、分级如何被感知。
|
||||
2. **产物格式**:生成结果必须遵循的可校验格式(schema)。描写字段要有粒度和地板。
|
||||
3. **池**(可选,默认无):优先 **方向池**(方向名 + 一句概括;默认不举例,至多点 1 个专名);**元素池**只给能数完、必须全员一致的封闭集合。无则 `池: []`。
|
||||
|
||||
本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
|
||||
|
||||
@@ -70,12 +70,19 @@ feeds: gm,auditor
|
||||
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
|
||||
4. 钉「数量」与「一次生成」(两件不同的事):
|
||||
- **数量**:这类对象总共要有几条(fixed / range / contextual)。
|
||||
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`(一次交出并对齐关系);彼此独立 → `单条`(一次只交 1 条,要更多就再点原型)。产物形状始终是 `records[]`,不因单条改成单对象。
|
||||
5. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
|
||||
6. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
|
||||
7. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
||||
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`;彼此独立 → `单条`。产物形状始终是 `records[]`。
|
||||
5. 写字段前先做设计判断(写入该 rule 的 `设计判断`):
|
||||
- 列出 **不可穷举项**(姓名、绰号、能力概念、外貌手感等理论上写不完的)→ 这些用方向池或自由写,禁止做成条目枚举。
|
||||
- 列出 **封闭集合**(阶层、强度档、有限类型名)→ 这些才用 `allowed_values` 或一个元素池。
|
||||
- 标签本身看不懂的分级(境界、品阶、强度档、好感档等)→ 必须另写 `分级表现`,把每档写成可感知的详细表象,不要只留字母/阶名。
|
||||
- 从本对象族真正用得上的面挑 3~6 个正交字段(是什么 / 能做什么 / 和谁关联 / 怎么被感知),不要摊通用名片。
|
||||
- 外貌、服装、仪式等由几面拼成一格的 → 写 `配方`(多方向组装进同一个顶层字段),不要拆成嵌套对象,也不要为每一面各建元素池。
|
||||
6. 写满规则:生成与描写(含配方、表现、工作流、反模式)+ 产物格式(**单层 JSON**)+ 池(需要才填)+ 需要时的分级表现。
|
||||
每个描写类 string 字段必须有 `粒度`(句子)和 `地板`(填到什么才算够)。`示例形状` 必须达到地板,禁止用单标签当示例。
|
||||
7. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);玩法接口不得漏(如 D&D 怪物的等级、攻击骰)。
|
||||
8. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
||||
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
|
||||
8. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
||||
9. 按自评四维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
||||
|
||||
若程序已发默认问题:禁止重复开场;在首答上补洞。
|
||||
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
|
||||
@@ -86,15 +93,21 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
```principles
|
||||
1. 整条规则先问必要性:时机 a(精准格式)或时机 b(原创会雾且还要生成)是否成立?两条都不成立则跳过,勿硬造表。
|
||||
2. 一调用一对象族;格式或用途不同则分次编排。
|
||||
3. 规则 = 描写/生成方法 + 产物格式;池是可选附属(可多池),不是必填第三设计。
|
||||
3. 规则 = 描写/生成方法 + 产物格式。池不是必填第三设计;默认 `池: []`。宁少池,勿为每个字段挂一套词表。
|
||||
4. 属性逐个过筛:每个字段必须对本对象族有意义;禁塞通用名片字段,也禁漏掉玩法接口字段。
|
||||
5. 产物格式 = **单层 JSON**:一条产物是一层扁平对象;值仅为标量或「标量数组」。
|
||||
禁止嵌套 object、禁止 object 数组。此格式要对齐动态表格列、旁观者→主世界层生成、以及投影取字段。
|
||||
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。
|
||||
6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。
|
||||
7. 生命周期先行;未定前不假装可执行「具体实例」。
|
||||
8. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系(亲属、对立、同场搭档等)→ `同批`;彼此独立 → `单条`。`同批` 至少 2 条,否则改 `单条`。不要把「总共要几个」(数量)和「这一次交几条」(一次生成)混成一件事。
|
||||
9. 不写变量更新公式、不写剧情推进、不填最终实例。
|
||||
6. 池分两种,先判断能不能数完:
|
||||
- **方向池**(默认):选项理论上无限。给 4~8 个方向;每条以**方向名 + 一句概括**为主。默认不举例;需要手感时至多点 1 个专名,禁止把方向写成作品名/例句清单。填写以方向为主:方向名可以当选项或标签;句子级字段按该方向写开。程序不抽方向池。
|
||||
- **元素池**:选项有限且必须全员一致(阶层、武器种类、强度档字母)。条目可穷举;旁观 `need_generate` 只抽这类池。
|
||||
姓名、绰号、能力概念、外貌、气质(作描写时)禁止元素池,也禁止用 `allowed_values` 假穷举。同一封闭集合不要既写枚举又再建元素池。
|
||||
7. 填值要有地板。骰子、是否可破坏可以短。外貌、能力现象、限制、互动手感必须写到可感知。`示例形状` 是地板的样板,不是「阴郁」这种标签。
|
||||
分级标签本身看不懂时:实例字段仍填短档位;详细对照写在 `分级表现`(每档 = 一句概括 + 一段可感知表象,相邻档必须能分清)。不要只丢「A / 金丹」让人猜。
|
||||
8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。
|
||||
9. 生命周期先行;未定前不假装可执行「具体实例」。
|
||||
10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。
|
||||
11. 不写变量更新公式、不写剧情推进、不填最终实例。
|
||||
```
|
||||
|
||||
## probe
|
||||
@@ -102,9 +115,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
```probe
|
||||
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
|
||||
|
||||
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(一次生成:同批 vs 单条);某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
|
||||
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系;某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。
|
||||
若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。
|
||||
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。
|
||||
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、再给哪个字段加一套枚举池。
|
||||
```
|
||||
|
||||
## output
|
||||
@@ -113,7 +126,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
{
|
||||
"schema": "context-fragment.v1",
|
||||
"技能": "生成规则",
|
||||
"brief": "一句话:对象族 + 建立规则|无需专门规则 + 生命周期(若建立)+ 有池|无池",
|
||||
"brief": "一句话:对象族 + 建立规则|无需专门规则 + 生命周期(若建立)+ 方向池|元素池|无池",
|
||||
"mount": ["world-simulator", "auditor"],
|
||||
"稳变": "stable",
|
||||
"正文": {
|
||||
@@ -149,16 +162,50 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
"模式": "单条|同批",
|
||||
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」"
|
||||
},
|
||||
"设计判断": {
|
||||
"不可穷举项": ["姓名", "能力概念", "外貌"],
|
||||
"封闭集合": ["类型", "阶层"],
|
||||
"正交字段依据": "一句话:为何是这几个字段而不是通用名片"
|
||||
},
|
||||
"生成与描写": {
|
||||
"依据": ["可较长;引用上游产物或用户要求"],
|
||||
"方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"],
|
||||
"配方": [
|
||||
{
|
||||
"目标字段": "外貌",
|
||||
"组件": ["轮廓方向", "质感方向"],
|
||||
"组装": "各取一个方向,拼进外貌这一格;不要拆成嵌套对象"
|
||||
}
|
||||
],
|
||||
"表现": [
|
||||
{
|
||||
"针对": "能力现象",
|
||||
"概括": "写到旁人能看见或听见的变化,以及对手能抓住的空隙",
|
||||
"例子": ["至多 1 条密度样板;没有就不写"]
|
||||
}
|
||||
],
|
||||
"工作流": ["按配方取方向并组装", "封闭档位对照分级表现", "按地板写描写格"],
|
||||
"方法": ["描写口径;方向为主(方向名可作选项/标签,句子级按该方向写开);元素池如何抽取"],
|
||||
"硬约束": ["任何产物不得违反"],
|
||||
"字段间约束": ["例:阵营=A 时权限≥3"],
|
||||
"字段间约束": ["有就写;无则 []"],
|
||||
"变化维度": ["允许随机或变化的部分;无则 []"],
|
||||
"禁止项": ["不得出现的组合或内容"],
|
||||
"去重规则": ["同批/跨批"],
|
||||
"校验": ["类型、枚举、边界、世界与体验一致性;以及是否按方法生成"]
|
||||
"反模式": ["为名字/能力/外貌建枚举条目池", "描写字段只填一个词", "方向池堆作品名或例句当选项", "分级只写阶名没有表象"],
|
||||
"校验": ["类型、枚举、边界、地板、配方是否组装进目标字段、分级表现是否可分档"]
|
||||
},
|
||||
"分级表现": [
|
||||
{
|
||||
"字段": "阶层",
|
||||
"说明": "实例只填档位值;叙事按本表展开。无此类分级则 []",
|
||||
"档位": [
|
||||
{
|
||||
"值": "档位名",
|
||||
"概括": "一句:这档能做到什么量级",
|
||||
"表象": "可感知的详细对照(看见/听见/碰到什么;须能和上下档分清)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"产物格式": {
|
||||
"格式": "单层JSON",
|
||||
"批量时": "array of flat object(每条记录单层)|单条则一个 flat object",
|
||||
@@ -167,8 +214,11 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
"type": "string|number|integer|boolean|enum|array",
|
||||
"required": true,
|
||||
"description": "语义 + 为何本对象族需要此字段;可较长",
|
||||
"粒度": "词条|方向|句子",
|
||||
"创作": "自由|约束|参考方向|必选元素",
|
||||
"地板": "描写类必填:填到什么才算够;词条/数字可省略",
|
||||
"allowed_values": [
|
||||
{ "value": "枚举值", "meaning": "含义" }
|
||||
{ "value": "仅封闭集合", "meaning": "含义" }
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
@@ -178,28 +228,32 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
}
|
||||
},
|
||||
"示例形状": {
|
||||
"名称": "……",
|
||||
"等级": 3,
|
||||
"攻击骰": "2d6+2",
|
||||
"气质": "阴郁"
|
||||
"名称": "达到地板的名字,不是「某某」",
|
||||
"阶层": "金丹",
|
||||
"外貌": "按配方拼好的一段可感知外貌,不是「黑发高个」",
|
||||
"能力描述": "写清现象与可被抓住的空隙;禁止一个词交差"
|
||||
}
|
||||
},
|
||||
"池": [
|
||||
{
|
||||
"pool_id": "英文 kebab-case;同规则内唯一",
|
||||
"名称": "人话名",
|
||||
"绑定字段": "单层 schema 的顶层字段名,例:气质 / 攻击花招",
|
||||
"用途": "灵感组装|随机抽取|枚举生成|其它",
|
||||
"说明": "该字段如何用本池;与「生成与描写.方法」如何配合;可较长",
|
||||
"池型": "方向|元素",
|
||||
"绑定字段": "单层 schema 的顶层字段名",
|
||||
"用途": "方向:方向为主的创作范围|元素:随机抽取或必选",
|
||||
"说明": "该字段如何用本池;多池绑同一字段时看配方",
|
||||
"方向": {
|
||||
"方向名": "一句概括。默认到此为止;至多再点 1 个专名。仅池型=方向时使用"
|
||||
},
|
||||
"条目": [
|
||||
{
|
||||
"id": "可选稳定 id",
|
||||
"内容": "材料正文或短标签;结构随池自定义,但同池宜一致",
|
||||
"内容": "仅池型=元素:可穷举的标签或短词",
|
||||
"权重": 1,
|
||||
"标签": ["可选分类标签"]
|
||||
}
|
||||
],
|
||||
"抽取或组装规则": "可较长;无特殊则写「均匀随机」或「按方法写入绑定字段」"
|
||||
"抽取或组装规则": "方向池写「以方向为主;句子级按该方向写开」;多池同字段看配方;元素池写抽取方式,默认均匀随机"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -216,12 +270,17 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
{
|
||||
"名": "属性妥当",
|
||||
"分数": 8,
|
||||
"说明": "逐字段:有无冗余(如女主规则的性别)或缺失(如 D&D 怪物缺等级/攻击骰);跳过时写 N/A"
|
||||
"说明": "逐字段:有无冗余或缺失玩法接口;跳过时写 N/A"
|
||||
},
|
||||
{
|
||||
"名": "材料得当",
|
||||
"分数": 8,
|
||||
"说明": "不可穷举项是否做成方向池/自由而非假穷举;方向是否以概括为主、例子极少;元素池是否仅封闭集合;该细的分级是否有表象;描写字段地板是否够密;跳过时写 N/A"
|
||||
},
|
||||
{
|
||||
"名": "格式准确",
|
||||
"分数": 8,
|
||||
"说明": "是否严格单层 JSON、类型/必填/枚举/边界可对接动态表格·旁观者生成·投影;跳过时写 N/A"
|
||||
"说明": "是否严格单层 JSON、类型/必填可对接动态表格·旁观者生成·投影;跳过时写 N/A"
|
||||
}
|
||||
],
|
||||
"薄弱点": "一句话"
|
||||
@@ -242,22 +301,26 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
|
||||
硬规则(公共三段 + 本技正文):
|
||||
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
|
||||
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
|
||||
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/材料得当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
|
||||
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。
|
||||
4. **每条 rule 必含**:生成与描写、产物格式、数量、一次生成;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
|
||||
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。
|
||||
6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
|
||||
7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
|
||||
8. `mount` 默认主世界层 + 旁观维护(旁观只需合同键:rule_id/格式/必要性;描写长文可在投影时 summary);**禁止**写 `order`。summary 见 task。
|
||||
4. **每条 rule 必含**:设计判断、生成与描写、产物格式、数量、一次生成;**池可空数组**。`生成与描写.配方` / `表现` 无则 `[]`。标签看不懂的分级必须有 `分级表现`(每档概括+表象,相邻可分清);无此类则 `分级表现: []`。有池时 `绑定字段` 必须是单层 schema 的顶层键,且必须标 `池型`。多个方向池绑同一字段时配方必须写清怎么拼进该格。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
|
||||
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。描写类 string 字段必须有 `粒度` 与 `地板`;`示例形状` 必须达到地板。
|
||||
6. **池型**:`方向` 必须有 `方向`(对象:方向名→一句概括,4~8 条;默认不举例,至多 1 个专名),`条目` 可 `[]` 或省略。`元素` 必须有可穷举 `条目`,不要写 `方向`。不可穷举项禁止 `池型=元素`,也禁止用 `allowed_values` 冒充。
|
||||
7. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
|
||||
8. 池≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
|
||||
9. `mount` 默认主世界层 + 旁观维护(旁观只需合同键:rule_id/格式/必要性;描写长文可在投影时 summary);**禁止**写 `order`。summary 见 task。
|
||||
|
||||
## checklist
|
||||
|
||||
```checklist
|
||||
- [ ] 含 schema / brief / 正文 / 自评 / 追问?
|
||||
- [ ] 自评为必要性 / 属性妥当 / 格式准确?
|
||||
- [ ] 自评为必要性 / 属性妥当 / 材料得当 / 格式准确?
|
||||
- [ ] 整条规则必要性有举证?不成立时 rules=[]?
|
||||
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?数量与一次生成已分清(总共几个 vs 这一次交几条)?同批有关系、单条写彼此独立?
|
||||
- [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表?
|
||||
- [ ] 建立时:有设计判断(不可穷举 vs 封闭集合)?逐字段无冗余无漏接口?产物为单层 JSON?数量与一次生成已分清?
|
||||
- [ ] 描写字段有粒度+地板,示例形状不是单标签?
|
||||
- [ ] 该细的分级有概括+可感知表象,不是只留阶名?
|
||||
- [ ] 多方向拼一格时有配方?表现是概括+极少例子?
|
||||
- [ ] 池:无则 [];有则标池型;方向池以概括为主、例子极少;元素池仅封闭集合;未把名字/能力做成枚举词表?
|
||||
- [ ] 未夹带最终实例、变量公式、其它对象族、百科?
|
||||
```
|
||||
|
||||
@@ -265,19 +328,21 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
|
||||
```examples
|
||||
好:
|
||||
- 武功典籍:单层含 名字/效果/阶层(时机 a)。
|
||||
- 「心之彩」:本局原创、听了会雾,故事里还要随机生成(时机 b)。
|
||||
- D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。
|
||||
- 女主规则:不设「性别」字段;关系切面进表;池可空。
|
||||
- 互为仇敌的两名开局 NPC:一次生成=同批,关系写清对立如何对齐;数量=2。
|
||||
- 可互换的酒馆路人:一次生成=单条,关系写「彼此独立」;要 5 个就点 5 次。
|
||||
- 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。
|
||||
- 现实名车、西幻常见种族 → 无需专门规则(常识能推且不需精准格式)。
|
||||
- 武功典籍:单层含 名字/效果/阶层。阶层短填 + `分级表现` 把每阶写成可感知对照;名字与效果用方向池(方向名+一句概括)。
|
||||
- 外貌:轮廓方向 × 质感方向,配方拼进 `外貌` 一格,不要 `{发色:{}}`。
|
||||
- 「心之彩」:颜色/性格用方向池(如「冷焰:静、刺、余烬不散」),不要列出 20 种色号,也不要堆作品名。
|
||||
- D&D 怪物:名称/等级/攻击骰齐全;等级若只写数字看不懂,补分级表现;气质用方向池+句子地板。
|
||||
- 女主规则:不设「性别」;池可空;内在冲突自由写并给地板。
|
||||
- 互为仇敌的两名开局 NPC:一次生成=同批;可互换的酒馆路人:一次生成=单条。
|
||||
- 现实名车、西幻常见种族 → 无需专门规则。
|
||||
|
||||
坏:
|
||||
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。
|
||||
- 为气质/花招/绰号/能力各建一个 8~20 词的元素池,填出来全是标签。
|
||||
- 方向池每条挂 3 个作品名或例句,变成假选项库。
|
||||
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层。
|
||||
- `示例形状` 里 `能力描述: "时空斩"` 或 `气质: "阴郁"` —— 地板被示例教坏。
|
||||
- 阶层/强度只有「A」「金丹」,没有表象,叙事时没法写。
|
||||
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
|
||||
- 有亲属关系的三人却标成单条,分三次各写一条,关系对不上。
|
||||
- 把「总共要 5 个」写成一次生成=同批,但彼此并无必须同场的关系。
|
||||
- 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。
|
||||
- 把写好的 NPC 整份塞进池当最终实例。
|
||||
```
|
||||
|
||||
@@ -32,4 +32,5 @@ prompt_excerpt: |
|
||||
多数轮保持空:need_generate=false、table_ops=[]、notes=[]。
|
||||
table_ops 仅在确需改「变量.当前」等表字段时使用(op=set|delta)。
|
||||
need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。
|
||||
程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。
|
||||
禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。
|
||||
|
||||
@@ -88,12 +88,12 @@ export class Blackboard {
|
||||
this.writeSeq += 1;
|
||||
const existing = this.getLatestByTag(input.tag);
|
||||
const item: BlackboardItem = {
|
||||
id: randomUUID(),
|
||||
id: existing?.id ?? randomUUID(),
|
||||
tag: input.tag,
|
||||
content: input.content,
|
||||
source: input.source,
|
||||
scope: input.scope ?? existing?.scope,
|
||||
createdAt: now,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: `${now}#${this.writeSeq}`,
|
||||
dependencies: input.dependencies,
|
||||
metadata: input.metadata,
|
||||
|
||||
@@ -421,7 +421,8 @@ export function canApplyEvent(
|
||||
reason?.kind === "approve_step" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "revision"
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "review_artifact"
|
||||
);
|
||||
case "creation_step_pick_awaited":
|
||||
return session.phase === "running";
|
||||
|
||||
+346
-71
@@ -55,7 +55,10 @@ import {
|
||||
extractUnitContentFromDraft,
|
||||
isDesignDiskWorker,
|
||||
isDesignUnitArtifact,
|
||||
normalizeCreationUnitId,
|
||||
parseAcceptedContentStore,
|
||||
parseAcceptedUnits,
|
||||
dropAcceptedUnitContent,
|
||||
upsertAcceptedUnitContent,
|
||||
} from "../skills/creation-units.js";
|
||||
import {
|
||||
@@ -80,6 +83,8 @@ import {
|
||||
spawnRepeatableCreationStep,
|
||||
spawnInstanceFromPrototype,
|
||||
removeUnstartedInstance,
|
||||
removeRepeatableInstance,
|
||||
pruneRepeatableArtifactContent,
|
||||
isPrototypeStep,
|
||||
isPrototypeSelectable,
|
||||
isInstanceStep,
|
||||
@@ -93,7 +98,6 @@ import {
|
||||
patchCreationFlowStepParams,
|
||||
pickRecordedStepId,
|
||||
resolveDesignStepBinding,
|
||||
shouldSkipModuleOpening,
|
||||
isProgressPointerTag,
|
||||
missingRequiredStepParams,
|
||||
stepUnitId,
|
||||
@@ -101,6 +105,7 @@ import {
|
||||
stringifyModuleOpeningState,
|
||||
finalizeCreationFlow,
|
||||
type CreationFlow,
|
||||
type CreationFlowStep,
|
||||
type CreationFlowStepParams,
|
||||
type ModuleCatalog,
|
||||
type ModuleCatalogEntry,
|
||||
@@ -185,6 +190,7 @@ import type {
|
||||
AcceptanceMode,
|
||||
ActiveSkillSnapshot,
|
||||
ApplyEventResult,
|
||||
ArtifactRecord,
|
||||
MainAgentDecision,
|
||||
PhaseEffect,
|
||||
RuntimeEvent,
|
||||
@@ -339,6 +345,71 @@ export class PhaseRuntime {
|
||||
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 则按开跑快照重跑;否则重跑总管。
|
||||
*/
|
||||
@@ -711,12 +782,19 @@ export class PhaseRuntime {
|
||||
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
|
||||
*/
|
||||
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 allowed =
|
||||
waiting === "worker_questions" ||
|
||||
waiting === "input" ||
|
||||
waiting === "revision" ||
|
||||
waiting === "approve_step";
|
||||
waiting === "approve_step" ||
|
||||
waiting === "review_artifact";
|
||||
if (!allowed) {
|
||||
throw new Error("当前不能返回节点选择");
|
||||
}
|
||||
@@ -724,6 +802,7 @@ export class PhaseRuntime {
|
||||
throw new Error("游玩中不能返回创作节点");
|
||||
}
|
||||
const stepId = this.readPinnedStepId();
|
||||
this.forgetUnansweredModuleOpening(stepId);
|
||||
const removed = this.unspawnCurrentInstanceIfEmpty();
|
||||
this.clearProposedStep();
|
||||
this.clearCurrentStepPointers();
|
||||
@@ -768,6 +847,42 @@ export class PhaseRuntime {
|
||||
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 {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
@@ -856,6 +971,85 @@ export class PhaseRuntime {
|
||||
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 {
|
||||
const content = stringifyCreationFlow(flow);
|
||||
this.blackboard.write({
|
||||
@@ -1164,7 +1358,7 @@ export class PhaseRuntime {
|
||||
case "run_worker":
|
||||
if (
|
||||
effect.workerId === DESIGN_STEP_WORKER_ID &&
|
||||
!this.hasPinnedReadyStep()
|
||||
!this.canRunPinnedDesignStep()
|
||||
) {
|
||||
await this.awaitCreationStepPick();
|
||||
break;
|
||||
@@ -1273,6 +1467,29 @@ export class PhaseRuntime {
|
||||
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: {
|
||||
stepId: string;
|
||||
name: string;
|
||||
@@ -1371,15 +1588,24 @@ export class PhaseRuntime {
|
||||
};
|
||||
flow = spawned.flow;
|
||||
step = spawned.step;
|
||||
} else if (reenter) {
|
||||
if (!isStepAccepted(step, accepted)) {
|
||||
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
|
||||
}
|
||||
} else if (!isStepReady(flow, step, accepted)) {
|
||||
} else if (!reenter && !isStepReady(flow, step, accepted)) {
|
||||
throw new Error(`「${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 auto = pendingModule?.auto === true;
|
||||
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
|
||||
@@ -1417,6 +1643,21 @@ export class PhaseRuntime {
|
||||
const waitingPick =
|
||||
this.session.waitingReason?.kind === "pick_creation_step";
|
||||
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) {
|
||||
const decision = createDecision({
|
||||
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) {
|
||||
this.onMessage(
|
||||
`[旁观维护] need_generate=${need.rule_id}:未找到可抽样的池(请检查设计.生成规则)`,
|
||||
`[旁观维护] need_generate=${need.rule_id}:无可程序抽样的元素池(方向池由填写方按规则自创)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2350,48 +2686,6 @@ export class PhaseRuntime {
|
||||
|
||||
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) {
|
||||
// 用户刚答完默认问题 → 标记已答,继续走 LLM
|
||||
state[stepKey] = "answered";
|
||||
@@ -2412,26 +2706,7 @@ export class PhaseRuntime {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (phase === "shown" && !this.session.resumeContext) {
|
||||
// 异常重入:开场已发过但未走 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;
|
||||
}
|
||||
|
||||
// 首次:发出默认问题
|
||||
// 首次,或开场已发出但用户还没答(离开节点 / 恢复丢了 resume):展示引导,等用户先说一轮
|
||||
state[stepKey] = "shown";
|
||||
const raw = stringifyModuleOpeningState(state);
|
||||
this.blackboard.write({
|
||||
|
||||
@@ -1410,6 +1410,31 @@ export class SessionManager {
|
||||
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> {
|
||||
const s = this.require(id);
|
||||
@@ -1686,6 +1711,27 @@ export class SessionManager {
|
||||
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(
|
||||
s: ManagedSession,
|
||||
@@ -1699,10 +1745,21 @@ export class SessionManager {
|
||||
return false;
|
||||
}
|
||||
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 (
|
||||
kind !== "worker_questions" &&
|
||||
kind !== "input" &&
|
||||
kind !== "revision"
|
||||
kind !== "revision" &&
|
||||
kind !== "approve_step"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -515,6 +515,12 @@ const server = createServer(async (req, res) => {
|
||||
typeof body.moduleName === "string" ? body.moduleName : "",
|
||||
);
|
||||
break;
|
||||
case "delete_step":
|
||||
view = await sessionManager.deleteStep(
|
||||
sessionId,
|
||||
typeof body.stepId === "string" ? body.stepId : "",
|
||||
);
|
||||
break;
|
||||
case "run_outline":
|
||||
view = await sessionManager.runOutline(sessionId);
|
||||
break;
|
||||
@@ -524,6 +530,9 @@ const server = createServer(async (req, res) => {
|
||||
case "retry_run":
|
||||
view = await sessionManager.abortAndRetry(sessionId);
|
||||
break;
|
||||
case "abort_run":
|
||||
view = await sessionManager.abortRun(sessionId);
|
||||
break;
|
||||
default:
|
||||
json(res, 400, { error: "未知 action" });
|
||||
return;
|
||||
|
||||
+95
-17
@@ -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) */
|
||||
function slugFromName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
@@ -2623,17 +2608,110 @@ export function removeUnstartedInstance(
|
||||
if (!step || !isInstanceStep(step) || isStepAccepted(step, acceptedStepIds)) {
|
||||
return flow;
|
||||
}
|
||||
return dropInstanceFromFlow(flow, step.id);
|
||||
}
|
||||
|
||||
function dropInstanceFromFlow(flow: CreationFlow, stepId: string): CreationFlow {
|
||||
return {
|
||||
...flow,
|
||||
steps: flow.steps
|
||||
.filter((s) => s.id !== step.id)
|
||||
.filter((s) => s.id !== stepId)
|
||||
.map((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:具体实例钉最近已验收规则实例;其余跟原型。 */
|
||||
function resolveInstanceDependsOn(params: {
|
||||
flow: CreationFlow;
|
||||
|
||||
@@ -622,6 +622,17 @@ export function extractUnitContentFromDraft(
|
||||
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(
|
||||
existingRaw: unknown,
|
||||
|
||||
@@ -13,6 +13,36 @@ export const MAINTAIN_GENERATE_TAG = "运行.本轮.旁观.生成抽样";
|
||||
|
||||
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 {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
@@ -61,6 +91,7 @@ export function buildChanceBatchFromGenerationRule(
|
||||
for (const poolRaw of pools) {
|
||||
if (!poolRaw || typeof poolRaw !== "object" || Array.isArray(poolRaw)) continue;
|
||||
const pool = poolRaw as Record<string, unknown>;
|
||||
if (!isElementPool(pool)) continue;
|
||||
const poolId =
|
||||
asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称);
|
||||
const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined;
|
||||
|
||||
+89
-26
@@ -28,6 +28,8 @@ import {
|
||||
hasSelectableCreationWork,
|
||||
hasAcceptedCloserStep,
|
||||
removeUnstartedInstance,
|
||||
removeRepeatableInstance,
|
||||
pruneRepeatableArtifactContent,
|
||||
looksLikeCreationFlowDoc,
|
||||
parseCreationFlow,
|
||||
pickRecordedStepId,
|
||||
@@ -37,7 +39,6 @@ import {
|
||||
parseRecipeYaml,
|
||||
parseSelectedRecipeRef,
|
||||
resolveDesignStepBinding,
|
||||
shouldSkipModuleOpening,
|
||||
isProgressPointerTag,
|
||||
stringifyCreationFlow,
|
||||
stripReviseSteps,
|
||||
@@ -776,31 +777,6 @@ modules:
|
||||
美学纲领与交互范式: "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(false);
|
||||
expect(isProgressPointerTag("设计.创作流程")).toBe(false);
|
||||
@@ -1253,6 +1229,93 @@ modules:
|
||||
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", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildChanceBatchFromGenerationRule,
|
||||
isElementPool,
|
||||
runMaintainNeedGenerateSampling,
|
||||
} 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?.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -658,6 +658,32 @@ describe("phase machine", () => {
|
||||
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", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
|
||||
+468
-1
@@ -3,12 +3,36 @@ import {
|
||||
createMockMainAgentResponse,
|
||||
createMockToolCall,
|
||||
MockLlmProvider,
|
||||
type LlmProvider,
|
||||
} from "../src/llm/client.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 { 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", () => {
|
||||
it("auto-loads default orchestrator and awaits first user input", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
@@ -178,6 +202,120 @@ describe("phase runtime", () => {
|
||||
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 () => {
|
||||
const llm = new MockLlmProvider(["Let me think about routing"]);
|
||||
const runtime = new PhaseRuntime({ llm, autoStubWorker: true });
|
||||
@@ -294,6 +432,335 @@ describe("phase runtime", () => {
|
||||
).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", () => {
|
||||
const session = createSession("default");
|
||||
const blackboard = new Blackboard();
|
||||
|
||||
+469
-53
@@ -197,8 +197,10 @@ function hideFlowNodeMenu() {
|
||||
const flowMenuState = {
|
||||
stepId: null,
|
||||
name: "",
|
||||
label: "",
|
||||
hasArtifact: false,
|
||||
canReenter: false,
|
||||
canDelete: false,
|
||||
node: null,
|
||||
handlers: null,
|
||||
};
|
||||
@@ -224,12 +226,16 @@ function showFlowNodeMenu(node, x, y, handlers) {
|
||||
if (!stepId) return;
|
||||
flowMenuState.stepId = stepId;
|
||||
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.canReenter = node.getAttribute("data-can-reenter") === "1";
|
||||
flowMenuState.canDelete = node.getAttribute("data-can-delete") === "1";
|
||||
flowMenuState.node = node;
|
||||
flowMenuState.handlers = handlers;
|
||||
const viewBtn = document.getElementById("flow-menu-view");
|
||||
const reenterBtn = document.getElementById("flow-menu-reenter");
|
||||
const deleteBtn = document.getElementById("flow-menu-delete");
|
||||
if (viewBtn) {
|
||||
viewBtn.disabled = !flowMenuState.hasArtifact;
|
||||
viewBtn.title = flowMenuState.hasArtifact ? "" : "这一步还没有产物";
|
||||
@@ -237,6 +243,9 @@ function showFlowNodeMenu(node, x, y, handlers) {
|
||||
if (reenterBtn) {
|
||||
reenterBtn.toggleAttribute("hidden", !flowMenuState.canReenter);
|
||||
}
|
||||
if (deleteBtn) {
|
||||
deleteBtn.toggleAttribute("hidden", !flowMenuState.canDelete);
|
||||
}
|
||||
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 stepId = flowMenuState.stepId;
|
||||
const name = flowMenuState.name;
|
||||
const label = flowMenuState.label || name;
|
||||
const handlers = flowMenuState.handlers;
|
||||
hideFlowNodeMenu();
|
||||
if (!stepId || !action) return;
|
||||
if (action === "view") handlers?.onViewStepArtifact?.(stepId, name);
|
||||
if (action === "reenter") handlers?.onReenterCreationStep?.(stepId);
|
||||
if (action === "delete") handlers?.onDeleteCreationStep?.(stepId, label);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2406,6 +2417,17 @@ function renderOneGenerationRuleCard(rule, index) {
|
||||
</header>`;
|
||||
|
||||
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") {
|
||||
const flags = Object.entries(rule.上下文策略)
|
||||
.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") {
|
||||
const g = rule.生成与描写;
|
||||
const methodKeys = ["依据", "方法", "硬约束", "字段间约束", "变化维度", "禁止项", "去重规则", "校验"];
|
||||
const methodKeys = ["依据", "工作流", "方法", "硬约束", "字段间约束", "变化维度", "禁止项", "去重规则", "反模式", "校验"];
|
||||
const extra = [
|
||||
renderGenerationRecipeHtml(g.配方),
|
||||
renderGenerationExpressHtml(g.表现),
|
||||
].filter(Boolean);
|
||||
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)
|
||||
.map(
|
||||
(k) =>
|
||||
@@ -2435,6 +2461,9 @@ function renderOneGenerationRuleCard(rule, index) {
|
||||
.join("")}</div>`,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(rule.分级表现) && rule.分级表现.length) {
|
||||
blocks.push(renderGenerationGradeHtml(rule.分级表现));
|
||||
}
|
||||
if (rule.产物格式 && typeof rule.产物格式 === "object") {
|
||||
blocks.push(renderProductSchemaCard(rule.产物格式));
|
||||
}
|
||||
@@ -2442,19 +2471,7 @@ function renderOneGenerationRuleCard(rule, index) {
|
||||
if (pools.length) {
|
||||
blocks.push(
|
||||
`<div class="skill-sub"><div class="skill-sub-title">池(${pools.length})</div><div class="skill-pool-grid">${pools
|
||||
.map((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>`;
|
||||
})
|
||||
.map((p) => renderGenerationPoolCard(p))
|
||||
.join("")}</div></div>`,
|
||||
);
|
||||
}
|
||||
@@ -2462,6 +2479,137 @@ function renderOneGenerationRuleCard(rule, index) {
|
||||
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) {
|
||||
const schema = fmt.schema && typeof fmt.schema === "object" ? fmt.schema : null;
|
||||
const fields = schema
|
||||
@@ -2474,6 +2622,9 @@ function renderProductSchemaCard(fmt) {
|
||||
const req = spec.required === true ? "必填" : spec.required === false ? "可选" : "—";
|
||||
const desc = spec.description != null ? clampPreviewText(String(spec.description), 80) : "";
|
||||
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) {
|
||||
extra.push(
|
||||
`枚举 ${spec.allowed_values
|
||||
@@ -3576,6 +3727,7 @@ function renderCreationFlowView(flowView, opts = {}) {
|
||||
<div class="flow-dag" data-flow-dag>
|
||||
<svg class="flow-dag-edges" aria-hidden="true"></svg>
|
||||
<div class="flow-dag-rows">${rows}</div>
|
||||
<aside class="flow-dag-hint" hidden role="status"></aside>
|
||||
</div>
|
||||
${suggestionHtml}
|
||||
</section>`;
|
||||
@@ -3670,6 +3822,11 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
const selectable = Boolean(s.selectable) && interactive;
|
||||
const doneMenu = !isProto && runState === "done";
|
||||
const canReenter = doneMenu && interactive;
|
||||
const canDelete =
|
||||
doneMenu &&
|
||||
interactive &&
|
||||
s.role === "instance" &&
|
||||
Boolean(s.repeatable);
|
||||
const runLabel = isProto
|
||||
? selectable
|
||||
? "可增殖"
|
||||
@@ -3685,6 +3842,8 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
? isProto
|
||||
? "增殖"
|
||||
: "进入"
|
||||
: canReenter
|
||||
? "进入"
|
||||
: "";
|
||||
const actionHtml = actionLabel
|
||||
? `<span class="flow-action">${esc(actionLabel)}</span>`
|
||||
@@ -3692,15 +3851,19 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
const doneMenuBtn = doneMenu
|
||||
? `<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 =
|
||||
!selectable && !doneMenu && s.blockedBy?.length
|
||||
? ` title="还差:${esc(s.blockedBy.join("、"))}"`
|
||||
!selectable && !doneMenu && blockedBy.length
|
||||
? ` title="还差:${esc(blockedBy.join("、"))}"`
|
||||
: isProto && selectable
|
||||
? ` title="确认当前编排,点一下新开一条产物再进入"`
|
||||
: selectable
|
||||
? ` title="确认当前编排并进入"`
|
||||
: doneMenu
|
||||
? ` title="查看产物或重新进入修改"`
|
||||
: canReenter
|
||||
? ` title="进入这条产物继续改,继承原文;新开一条请点原型上的「增殖」"`
|
||||
: "";
|
||||
const currentAttr = runState === "current" ? ' aria-current="step"' : "";
|
||||
const clickable = selectable || doneMenu;
|
||||
@@ -3708,9 +3871,9 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
const typeAttr = clickable ? ' type="button"' : "";
|
||||
const pickAttr = selectable ? ` data-flow-pick="${esc(s.id)}"` : "";
|
||||
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"
|
||||
: doneMenu
|
||||
? " is-done-menu"
|
||||
@@ -3724,9 +3887,9 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
const originAttr =
|
||||
origin?.fromId ? ` data-flow-origin="${escAttr(origin.fromId)}"` : "";
|
||||
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}`;
|
||||
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">
|
||||
${compact ? "" : `<span class="flow-run">${esc(runLabel)}</span>`}
|
||||
<span class="flow-name">${nameLabel}${occ}</span>
|
||||
@@ -3741,6 +3904,47 @@ function renderFlowGraphNode(s, interactive, opts = {}) {
|
||||
</${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) {
|
||||
if (!dag) return;
|
||||
const svg = dag.querySelector(".flow-dag-edges");
|
||||
@@ -3750,15 +3954,6 @@ function paintFlowDagEdges(dag) {
|
||||
svg.replaceChildren();
|
||||
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 w = Math.max(dag.scrollWidth, dag.clientWidth, 1);
|
||||
const h = Math.max(dag.scrollHeight, dag.clientHeight, 1);
|
||||
@@ -3766,20 +3961,17 @@ function paintFlowDagEdges(dag) {
|
||||
svg.setAttribute("height", String(h));
|
||||
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
||||
|
||||
const findNode = flowDagNodeIndex(dag);
|
||||
const paths = [];
|
||||
for (const el of nodes) {
|
||||
let deps = [];
|
||||
try {
|
||||
deps = JSON.parse(el.getAttribute("data-flow-deps") || "[]");
|
||||
} catch {
|
||||
deps = [];
|
||||
}
|
||||
if (!Array.isArray(deps) || !deps.length) continue;
|
||||
const deps = parseFlowJsonList(el.getAttribute("data-flow-deps"));
|
||||
if (!deps.length) continue;
|
||||
const toId = el.getAttribute("data-flow-id") || "";
|
||||
const toRect = el.getBoundingClientRect();
|
||||
const x2 = toRect.left - dagRect.left + toRect.width / 2 + dag.scrollLeft;
|
||||
const y2 = toRect.top - dagRect.top + dag.scrollTop;
|
||||
for (const ref of deps) {
|
||||
const from = findNode(String(ref));
|
||||
const from = findNode(ref);
|
||||
if (!from || from === el) continue;
|
||||
const fromId = from.getAttribute("data-flow-id") || "";
|
||||
if (el.getAttribute("data-flow-from") === fromId) continue;
|
||||
@@ -3787,19 +3979,15 @@ function paintFlowDagEdges(dag) {
|
||||
const x1 =
|
||||
fromRect.left - dagRect.left + fromRect.width / 2 + dag.scrollLeft;
|
||||
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 belong = el.getAttribute("data-flow-origin") === fromId;
|
||||
const active =
|
||||
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 cls = `flow-dag-edge${belong ? " flow-dag-edge--belong" : ""}`;
|
||||
const marker = belong
|
||||
? "url(#flow-dag-arrow-belong)"
|
||||
: "url(#flow-dag-arrow)";
|
||||
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">
|
||||
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead" />
|
||||
</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">
|
||||
<path d="M 0 1 L 8 5 L 0 9 z" class="flow-dag-arrowhead flow-dag-arrowhead--belong" />
|
||||
</marker>
|
||||
</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) {
|
||||
@@ -3825,18 +4213,42 @@ function wireCreationFlowGraph(root, handlers) {
|
||||
});
|
||||
});
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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("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();
|
||||
if (canReenter && stepId) {
|
||||
handlers.onReenterCreationStep?.(stepId);
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
showFlowNodeMenu(el, rect.left, rect.bottom + 4, handlers);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-flow-spawn]").forEach((el) => {
|
||||
@@ -3863,6 +4275,7 @@ function wireCreationFlowGraph(root, handlers) {
|
||||
dag.querySelectorAll(".flow-family").forEach((n) => ro.observe(n));
|
||||
}
|
||||
window.addEventListener("resize", paint, { passive: true });
|
||||
wireFlowDagTrace(dag);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3995,6 +4408,7 @@ function renderReviewWorkspace(review, opts = {}) {
|
||||
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}">
|
||||
<header class="workspace-review-head workspace-review-head--slim">
|
||||
${opts.leaveStepHtml || ""}
|
||||
<div class="workspace-review-titles">
|
||||
<span class="workspace-review-kicker">${esc(copy.kicker)}</span>
|
||||
<h2 class="workspace-review-title">${esc(label)}</h2>
|
||||
@@ -4512,6 +4926,7 @@ function renderSpeakWorkspace(view) {
|
||||
: "";
|
||||
return `<section class="workspace-intent">
|
||||
<div class="workspace-intent-head">
|
||||
${leaveStepControlHtml(view)}
|
||||
${
|
||||
flowInteractive
|
||||
? workspaceViewSwitchHtml("flow")
|
||||
@@ -4519,7 +4934,6 @@ function renderSpeakWorkspace(view) {
|
||||
canOfferSaveProduct(view) ? "落档" : "说话"
|
||||
}</span>`
|
||||
}
|
||||
${leaveStepControlHtml(view)}
|
||||
</div>
|
||||
<h2 class="workspace-intent-title">${esc(title)}</h2>
|
||||
<p class="workspace-intent-hint">${esc(hint)}</p>
|
||||
@@ -4534,9 +4948,9 @@ function renderSpeakWorkspace(view) {
|
||||
function renderAnswerWorkspaceShell(view) {
|
||||
return `<section class="workspace-answer" id="workspace-answer-slot">
|
||||
<header class="workspace-answer-head workspace-answer-head--slim">
|
||||
${leaveStepControlHtml(view)}
|
||||
<span class="workspace-surface-kicker">答题</span>
|
||||
<p class="workspace-answer-hint">点字母选中,文案可改;点卡片展开,点顶条收起。</p>
|
||||
${leaveStepControlHtml(view)}
|
||||
</header>
|
||||
</section>`;
|
||||
}
|
||||
@@ -4602,10 +5016,12 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) {
|
||||
renderReviewWorkspace(view.reviewArtifact, {
|
||||
hideAskSidecar: opts.hideAskSidecar === true,
|
||||
sourceMessage: resolveReviewSourceMessage(view),
|
||||
leaveStepHtml: leaveStepControlHtml(view),
|
||||
}) + askSlot;
|
||||
wireMessageFeedActions(stage, handlers);
|
||||
wireMessageContextMenu(stage, handlers);
|
||||
wireCreationFlowGraph(stage, handlers);
|
||||
wireLeaveStepControl(stage, handlers);
|
||||
wireOpeningPicker();
|
||||
if (opts.mountAskCard) {
|
||||
const askHost = document.getElementById("workspace-review-ask");
|
||||
@@ -4696,13 +5112,13 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
||||
stage.innerHTML = `
|
||||
<div class="workspace-pending" id="msg-live-pending">
|
||||
<header class="workspace-pending-head">
|
||||
${leaveStepControlHtml(view)}
|
||||
<span class="msg-tag">${esc(label)}</span>
|
||||
<span class="msg-live-indicator">流式输出中</span>
|
||||
${renderRetryRunButton()}
|
||||
</header>
|
||||
<div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div>
|
||||
</div>`;
|
||||
wireRetryRun(stage, handlers);
|
||||
wireLeaveStepControl(stage, handlers);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+38
-22
@@ -546,7 +546,7 @@ function resolveComposer(view, loading) {
|
||||
|
||||
function composerModeChip(spec) {
|
||||
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 tip = [spec.taskTitle, spec.taskHint].filter(Boolean).join(" — ");
|
||||
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() {
|
||||
if (skipComposerStash) {
|
||||
skipComposerStash = false;
|
||||
@@ -650,20 +643,22 @@ function renderComposer(view, loading) {
|
||||
host._enterHandler = null;
|
||||
}
|
||||
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") {
|
||||
root.innerHTML = `<div class="composer-waiting">
|
||||
<span>${esc(spec.text)}</span>
|
||||
<button type="button" class="btn" data-act="retry_run" title="停止当前生成并重试">停止并重试</button>
|
||||
</div>`;
|
||||
const shell = composerInputShell(
|
||||
{ ...spec, task: { ...(spec.task || {}), id: "busy", label: spec.task?.label || "生成中" } },
|
||||
{
|
||||
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());
|
||||
applyPendingComposerDraft();
|
||||
const input = $("composer-input");
|
||||
if (input) autosizeComposerInput(input);
|
||||
return;
|
||||
}
|
||||
if (spec.mode === "idle") {
|
||||
@@ -878,7 +873,7 @@ function renderComposer(view, loading) {
|
||||
: spec.placeholder;
|
||||
const shell = composerInputShell(spec, {
|
||||
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" : ""
|
||||
}" data-act="revise" title="${esc(
|
||||
acceptOnEmpty
|
||||
@@ -889,7 +884,6 @@ function renderComposer(view, loading) {
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
wireComposerForm({ acceptOnEmpty });
|
||||
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
|
||||
root.querySelector("[data-act=leave_step]")?.addEventListener("click", () => runAction("leave_step"));
|
||||
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() {
|
||||
stopLivePoll();
|
||||
if (!sessionId) return;
|
||||
@@ -2009,6 +2020,11 @@ function renderSession(view, loading = false) {
|
||||
onReenterCreationStep: (stepId) =>
|
||||
runAction("pick_step", { stepId, reenter: true }),
|
||||
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 }),
|
||||
onLeaveCreationStep: () => runAction("leave_step"),
|
||||
onEnterPlay: () => enterPlayNow(),
|
||||
|
||||
+2
-1
@@ -233,7 +233,8 @@
|
||||
|
||||
<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-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>
|
||||
|
||||
<dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title">
|
||||
|
||||
+168
-21
@@ -479,21 +479,26 @@ body.design-workspace.coord-rail-collapsed .main-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.workspace-review-head .workspace-leave-step {
|
||||
flex-shrink: 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.workspace-review-titles {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.workspace-review-nav {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.workspace-review-nav .msg-variant-nav {
|
||||
margin: 0;
|
||||
@@ -860,6 +865,9 @@ body.design-workspace .workspace-pending {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.workspace-pending-head .workspace-leave-step {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.workspace-pending-head .msg-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -948,7 +956,7 @@ body.design-workspace .review-worker-set {
|
||||
.workspace-intent-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
max-width: 46rem;
|
||||
@@ -979,7 +987,7 @@ body.design-workspace .review-worker-set {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.workspace-answer-head--slim .workspace-leave-step {
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.workspace-intent,
|
||||
.workspace-answer {
|
||||
@@ -1213,7 +1221,7 @@ body.design-workspace .flow-status {
|
||||
body.design-workspace .flow-dag {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 4px 2px 8px;
|
||||
padding: 8px 8px 16px;
|
||||
}
|
||||
body.design-workspace .flow-dag-edges {
|
||||
position: absolute;
|
||||
@@ -1225,23 +1233,41 @@ body.design-workspace .flow-dag-edges {
|
||||
overflow: visible;
|
||||
}
|
||||
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;
|
||||
opacity: 0.85;
|
||||
}
|
||||
body.design-workspace .flow-dag-edge.is-lit {
|
||||
stroke: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
opacity: 1;
|
||||
opacity: 0.55;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
gap: 44px;
|
||||
min-width: 0;
|
||||
}
|
||||
body.design-workspace .flow-dag-row {
|
||||
@@ -1262,8 +1288,96 @@ body.design-workspace .flow-dag-row-nodes {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 24px 104px;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1321,10 +1435,13 @@ body.design-workspace .flow-node-menu-btn {
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
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: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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:hover {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 35%, 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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
body.design-workspace .flow-spawn-meta .flow-action {
|
||||
min-height: 24px;
|
||||
@@ -1495,7 +1611,11 @@ body.design-workspace .flow-dag-edge--belong {
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
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 {
|
||||
font-size: 12px;
|
||||
@@ -1604,6 +1724,8 @@ body.design-workspace .flow-spawn.is-blocked {
|
||||
body.design-workspace .flow-dag-row-nodes {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
body.design-workspace .flow-node,
|
||||
body.design-workspace .flow-family {
|
||||
@@ -3559,8 +3681,21 @@ body.design-workspace .af-mosaic {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.skill-pool-desc {
|
||||
margin: 6px 0 0;
|
||||
.skill-pool-card.is-direction {
|
||||
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;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
@@ -4821,7 +4956,7 @@ body.design-workspace .af-mosaic {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.composer-mode-chip {
|
||||
@@ -4861,6 +4996,10 @@ body.design-workspace .af-mosaic {
|
||||
.composer-input-shell[data-task="review"][data-tone="plan"] .composer-mode-chip {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -4917,6 +5056,7 @@ body.design-workspace .af-mosaic {
|
||||
.composer-shell-actions > .btn-primary,
|
||||
.composer-shell-actions > .composer-btn,
|
||||
.composer-shell-actions > .composer-btn-accept,
|
||||
.composer-shell-actions > .composer-btn-icon,
|
||||
.composer-form > .btn,
|
||||
.composer-form > .btn-primary,
|
||||
.composer-form > .composer-btn,
|
||||
@@ -4933,6 +5073,13 @@ body.design-workspace .af-mosaic {
|
||||
white-space: nowrap;
|
||||
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 {
|
||||
background: #15803d;
|
||||
|
||||
Reference in New Issue
Block a user