diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index 3f3ef4a..f663a57 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -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);**应补专用卡** | diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md index 3e4edcd..dbaf875 100644 --- a/docs/world-simulator-modules.md +++ b/docs/world-simulator-modules.md @@ -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` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) | diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md index 1a5b65e..c9b9057 100644 --- a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -57,8 +57,7 @@ feeds: design_only 一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。 -若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。 -开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。 +若程序已发出默认问题:用户首答在「用户.worker答复」,开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。黑板「用户.需求」里已有的句子当作补充材料采纳,不代替本步引导作答。 ``` ## principles @@ -79,7 +78,7 @@ feeds: design_only ```probe 本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。 -用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉;开场若已跳过,把「用户.需求」首句当答复。已答清的不重问。 +用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉。已答清的不重问。 缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。 diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml index 705dff4..1051001 100644 --- a/skills/dialogue/world-simulator/modules/catalog.yaml +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -49,7 +49,7 @@ modules: repeatable: true declaration: >- 要精准格式,或本局原创导致知识中无可参考对象、或难以在无指导情况下生成足够可信内容且故事里还要生成时:写出该对象族怎么生成; - 不填最终实例;可按对象反复 + 创意字段用方向池,不堆枚举词;不填最终实例;可按对象反复 artifact: 设计.生成规则 params: - key: target diff --git a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md index c0cd3ee..95ceb9c 100644 --- a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md +++ b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md @@ -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。 +坏:句子级只填「阴郁」;或按方向说明里那个专名点菜交差。 ``` diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md index 6db11f0..4782652 100644 --- a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -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 整份塞进池当最终实例。 ``` diff --git a/skills/dialogue/world-simulator/worker-templates/auditor.yaml b/skills/dialogue/world-simulator/worker-templates/auditor.yaml index c83d7bb..d18aec1 100644 --- a/skills/dialogue/world-simulator/worker-templates/auditor.yaml +++ b/skills/dialogue/world-simulator/worker-templates/auditor.yaml @@ -32,4 +32,5 @@ prompt_excerpt: | 多数轮保持空:need_generate=false、table_ops=[]、notes=[]。 table_ops 仅在确需改「变量.当前」等表字段时使用(op=set|delta)。 need_generate 仅在确需按某条 rule_id 补实例时写成 { "rule_id", "reason" }。 + 程序只会抽该规则里的元素池;方向池由主世界层按规则自创,不要指望抽签出标签。 禁止编造长对话续写;禁止输出用户可见正文;禁止修改未点名的无关字段。 diff --git a/src/blackboard/blackboard.ts b/src/blackboard/blackboard.ts index 10de306..8283a5a 100644 --- a/src/blackboard/blackboard.ts +++ b/src/blackboard/blackboard.ts @@ -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, diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index bceb005..da915b0 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -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"; diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 6174a84..4b9a598 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -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 { + 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 { + 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({ diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index f731266..6ddb460 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -1410,6 +1410,31 @@ export class SessionManager { return this.toView(id); } + /** 中止当前生成,不重跑;回到生成前的等待面或分层图。 */ + async abortRun(id: string): Promise { + 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 { const s = this.require(id); @@ -1686,6 +1711,27 @@ export class SessionManager { return this.toView(id); } + async deleteStep(id: string, stepId: string): Promise { + 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; } diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 527e307..6a475b8 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -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; diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index dbc12c7..827a3e2 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -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 = { @@ -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; + const bodyRaw = doc.正文; + if (!bodyRaw || typeof bodyRaw !== "object" || Array.isArray(bodyRaw)) { + return null; + } + const body = bodyRaw as Record; + 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).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; diff --git a/src/skills/creation-units.ts b/src/skills/creation-units.ts index 7c3fa17..60e05fe 100644 --- a/src/skills/creation-units.ts +++ b/src/skills/creation-units.ts @@ -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, diff --git a/src/skills/maintain-need-generate.ts b/src/skills/maintain-need-generate.ts index f681828..6fceb88 100644 --- a/src/skills/maintain-need-generate.ts +++ b/src/skills/maintain-need-generate.ts @@ -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 | undefined { + return ( + asString(pool.池型) ?? + asString(pool.kind) ?? + asString(pool.类型) ?? + asString(pool.pool_type) + ); +} + +/** 方向池给 LLM 当创作范围,不能程序加权抽。 */ +export function isElementPool(pool: Record): 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; + if (!isElementPool(pool)) continue; const poolId = asString(pool.pool_id) ?? asString(pool.池id) ?? asString(pool.名称); const entries = (pool.条目 ?? pool.entries) as PoolEntry[] | undefined; diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index 74f844e..54964b9 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -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": [ diff --git a/tests/maintain-need-generate.test.ts b/tests/maintain-need-generate.test.ts index 6031258..813a1a7 100644 --- a/tests/maintain-need-generate.test.ts +++ b/tests/maintain-need-generate.test.ts @@ -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(); + }); }); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index 9470a3e..31ba40d 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -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 = { diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index a8cef6c..3791c31 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -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(); diff --git a/web/agent-ui.js b/web/agent-ui.js index 15bfe36..3c0b7f4 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -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) { `; const blocks = []; + if (rule.设计判断 && typeof rule.设计判断 === "object") { + const d = rule.设计判断; + const unlist = (v) => + Array.isArray(v) && v.length ? v.map(String).join("、") : ""; + blocks.push( + `
设计判断
${skillChipRow([ + unlist(d.不可穷举项) && `不可穷举 · ${unlist(d.不可穷举项)}`, + unlist(d.封闭集合) && `封闭 · ${unlist(d.封闭集合)}`, + ])}${d.正交字段依据 ? `

${esc(String(d.正交字段依据))}

` : ""}
`, + ); + } if (rule.上下文策略 && typeof rule.上下文策略 === "object") { const flags = Object.entries(rule.上下文策略) .map(([k, v]) => `${esc(k)}`) @@ -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( - `
生成与描写
${methodKeys + `
生成与描写
${extra.join("")}${methodKeys .filter((k) => Array.isArray(g[k]) && g[k].length) .map( (k) => @@ -2435,6 +2461,9 @@ function renderOneGenerationRuleCard(rule, index) { .join("")}
`, ); } + 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( `
池(${pools.length})
${pools - .map((p) => { - if (!p || typeof p !== "object") return ""; - const n = Array.isArray(p.条目) ? p.条目.length : 0; - return `
-
${esc(String(p.名称 || p.pool_id || "池"))}
- ${skillChipRow([ - p.绑定字段 && `绑 · ${p.绑定字段}`, - p.用途 && String(p.用途), - n ? `${n} 条` : "", - ])} - ${p.说明 ? `

${esc(clampPreviewText(String(p.说明), 120))}

` : ""} -
`; - }) + .map((p) => renderGenerationPoolCard(p)) .join("")}
`, ); } @@ -2462,6 +2479,137 @@ function renderOneGenerationRuleCard(rule, index) { return `
${head}${blocks.join("")}
`; } +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 `
+
配方 · ${esc(String(row.目标字段 || "字段"))}
+ ${skillChipRow([comps && `组件 · ${comps}`])} + ${row.组装 ? `

${esc(String(row.组装))}

` : ""} +
`; + }) + .filter(Boolean); + return cards.length + ? `
配方
${cards.join("")}
` + : ""; +} + +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 `
+
${esc(String(row.针对 || "表现"))}
+ ${row.概括 ? `

${esc(String(row.概括))}

` : ""} + ${ + ex.length + ? `
    ${ex.map((x) => `
  • ${esc(String(x))}
  • `).join("")}
` + : "" + } +
`; + }) + .filter(Boolean); + return cards.length + ? `
表现
${cards.join("")}
` + : ""; +} + +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 ` + ${esc(String(g.值 || ""))} + ${esc(String(g.概括 || ""))} + ${esc(String(g.表象 || ""))} + `; + }) + .join(""); + return `
+
分级表现 · ${esc(String(row.字段 || ""))}
+ ${row.说明 ? `

${esc(String(row.说明))}

` : ""} + ${ + tr + ? `
${tr}
档概括表象
` + : "" + } +
`; + }) + .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 + ? `
${dirs + .map( + (d) => + `
${esc(d.name || "方向")}
${ + d.text ? `
${esc(clampPreviewText(d.text, 160))}
` : "" + }
`, + ) + .join("")}
` + : ""; + return `
+
${esc(String(p.名称 || p.pool_id || "池"))}
+ ${skillChipRow([ + kind && `${kind}池`, + p.绑定字段 && `绑 · ${p.绑定字段}`, + p.用途 && String(p.用途), + kind === "元素" && n ? `${n} 条` : "", + kind === "方向" && dirs.length ? `${dirs.length} 向` : "", + ])} + ${p.说明 ? `

${esc(clampPreviewText(String(p.说明), 160))}

` : ""} + ${dirHtml} +
`; +} + 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 = {}) {
${rows}
+
${suggestionHtml} `; @@ -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,22 +3842,28 @@ function renderFlowGraphNode(s, interactive, opts = {}) { ? isProto ? "增殖" : "进入" - : ""; + : canReenter + ? "进入" + : ""; const actionHtml = actionLabel ? `${esc(actionLabel)}` : ""; const doneMenuBtn = doneMenu ? `` : ""; + 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 - ? `${actionHtml || doneMenuBtn || `${esc(runLabel)}`}` + ? `${actionHtml}${doneMenuBtn || (!actionHtml ? `${esc(runLabel)}` : "")}` : `${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}> ${compact ? "" : `${esc(runLabel)}`} ${nameLabel}${occ} @@ -3741,6 +3904,47 @@ function renderFlowGraphNode(s, interactive, opts = {}) { `; } +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( - ``, + ``, ); } } @@ -3807,10 +3995,210 @@ function paintFlowDagEdges(dag) { + + + + + + ${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 `

${esc(emptyText)}

`; + } + return `
    ${items + .map( + (item) => + `
  • ${esc(item.name)}${esc(item.run)}
  • `, + ) + .join("")}
`; +} + +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 = `

依赖关系

+

${esc(flowNodeHintName(node))}

+
+
依赖
+ ${flowHintItemsHtml(deps, "无前置")} +
+
+
下游
+ ${flowHintItemsHtml(downstream, "无下游")} +
`; + 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,16 +4213,40 @@ 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); }); @@ -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 `
+ ${opts.leaveStepHtml || ""}
${esc(copy.kicker)}

${esc(label)}

@@ -4512,6 +4926,7 @@ function renderSpeakWorkspace(view) { : ""; return `
+ ${leaveStepControlHtml(view)} ${ flowInteractive ? workspaceViewSwitchHtml("flow") @@ -4519,7 +4934,6 @@ function renderSpeakWorkspace(view) { canOfferSaveProduct(view) ? "落档" : "说话" }` } - ${leaveStepControlHtml(view)}

${esc(title)}

${esc(hint)}

@@ -4534,9 +4948,9 @@ function renderSpeakWorkspace(view) { function renderAnswerWorkspaceShell(view) { return `
+ ${leaveStepControlHtml(view)} 答题

点字母选中,文案可改;点卡片展开,点顶条收起。

- ${leaveStepControlHtml(view)}
`; } @@ -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 = `
+ ${leaveStepControlHtml(view)} ${esc(label)} 流式输出中 - ${renderRetryRunButton()}
${renderLiveStreamBody(live, view)}
`; - wireRetryRun(stage, handlers); + wireLeaveStepControl(stage, handlers); return; } diff --git a/web/app.js b/web/app.js index b27cf26..b5764f1 100644 --- a/web/app.js +++ b/web/app.js @@ -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 ``; -} - 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 = `
- ${esc(spec.text)} - -
`; + const shell = composerInputShell( + { ...spec, task: { ...(spec.task || {}), id: "busy", label: spec.task?.label || "生成中" } }, + { + textareaHtml: ``, + trailing: ` + `, + }, + ); + root.innerHTML = `
${shell}
`; + 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: ``, - trailing: `${leaveStepButtonHtml(spec)} - + + diff --git a/web/styles.css b/web/styles.css index 0cd7661..b5fefa7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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;