From b82da4c5a597affbb19488330ea7019816b30726 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 24 Aug 2026 01:13:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=B8=B8=E7=8E=A9=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E4=B8=8E=E5=BC=80=E5=9C=BA=E6=94=B6=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=8F=AF=E6=9B=BF=E6=8D=A2=20agent=20?= =?UTF-8?q?=E5=8F=B8=E6=9C=BA=E5=B9=B6=E6=94=AF=E6=8C=81=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E6=AD=A2=EF=BC=9B=E5=AE=8C=E5=96=84=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E6=B4=9E=E4=BD=8D=E3=80=81=E4=B8=96=E7=95=8C=E4=B9=A6=E6=8B=BC?= =?UTF-8?q?=E8=A3=85=E4=B8=8E=E8=AF=95=E8=B7=91=EF=BC=8C=E5=B9=B6=E6=89=93?= =?UTF-8?q?=E7=A3=A8=E4=BC=9A=E8=AF=9D=E8=BF=90=E8=A1=8C=E6=97=B6=E4=B8=8E?= =?UTF-8?q?=20Web=20=E4=BA=A4=E4=BA=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/briefs/artifact-card-coverage.md | 4 +- docs/briefs/capability-authoring-brief.md | 19 +- docs/context-fragment-design.md | 8 +- docs/creation-playbook.md | 6 +- docs/play-presentation-shells.md | 7 +- docs/preset-format.md | 85 +- docs/run-snapshot.md | 2 +- docs/ui-glossary.md | 6 +- docs/world-simulator-modules.md | 44 +- skills/dialogue/world-simulator/README.md | 2 +- .../modules/aesthetics-interaction/prompt.md | 23 +- .../world-simulator/modules/catalog.yaml | 41 +- .../modules/concrete-instances/prompt.md | 18 +- .../modules/context-order/prompt.md | 141 ++-- .../modules/generation-rules/prompt.md | 21 +- .../modules/narrative/prompt.md | 39 +- .../modules/opening-setup/prompt.md | 42 +- .../modules/reply-format/prompt.md | 65 +- .../recipes/world-simulator/recipe.yaml | 4 +- .../worker-templates/narrator.yaml | 2 + .../workers/design-flow/SKILL.md | 55 +- .../workers/design-step/SKILL.md | 37 +- .../workers/opening-generator/SKILL.md | 15 +- src/book/snapshot-filters.ts | 13 +- src/llm/client.ts | 107 +-- src/llm/preset-wrapper.ts | 108 ++- src/llm/run-abort.ts | 30 + src/llm/token-tracker.ts | 41 +- src/log.ts | 3 + src/main-agent/main-agent.ts | 11 +- src/main-agent/tool-loop.ts | 167 ++-- src/preset/assembler.ts | 10 +- src/preset/importer.ts | 32 +- src/preset/markers.ts | 132 ++++ src/preset/play-frame.ts | 24 + src/preset/probe.ts | 130 +++ src/preset/world-info-pack.ts | 157 ++++ src/runtime/driver.ts | 161 ++++ src/runtime/llm-factory.ts | 30 +- src/runtime/orchestrator.ts | 2 + src/runtime/phase-machine.ts | 172 +++- src/runtime/phase-runtime.ts | 265 ++++++- src/server/agent-view.ts | 11 +- src/server/display-labels.ts | 7 +- src/server/session-manager.ts | 618 +++++++++++++-- src/server/settings-handlers.ts | 51 ++ src/server/skill-catalog.ts | 2 +- src/server/web-server.ts | 20 +- src/skills/context-fragment.ts | 29 +- src/skills/context-order.ts | 66 +- src/skills/context-segments.ts | 198 ++++- src/skills/creation-flow.ts | 379 +++++++-- src/skills/declared-worker.ts | 17 +- src/skills/loader.ts | 46 +- src/skills/opening-seal.ts | 200 +++++ src/skills/play-slots.ts | 51 ++ src/skills/play-turn.ts | 33 + src/skills/present-packet.ts | 27 +- src/skills/worker-declaration.ts | 6 +- src/skills/worker-llm.ts | 26 +- src/types/context-trace.ts | 49 +- src/types/runtime.ts | 13 +- src/worker/executor.ts | 209 +++-- tests/context-assembly.test.ts | 120 ++- tests/context-fragment.test.ts | 75 ++ tests/creation-flow.test.ts | 204 ++++- tests/declared-worker.test.ts | 33 +- tests/display-labels.test.ts | 6 + tests/driver.test.ts | 102 +++ tests/opening-seal.test.ts | 74 ++ tests/phase-machine.test.ts | 230 +++++- tests/phase-runtime.test.ts | 80 ++ tests/play-frame.test.ts | 98 +++ tests/play-turn.test.ts | 28 + tests/present-packet.test.ts | 25 + tests/preset-probe.test.ts | 123 +++ tests/preset-wrapper.test.ts | 99 +++ tests/preset.test.ts | 61 +- tests/run-abort.test.ts | 31 + tests/snapshot-filters.test.ts | 11 +- tests/worker-declaration.test.ts | 9 + tests/worker-set-sanitize.test.ts | 57 +- tests/world-info-pack.test.ts | 70 ++ web/agent-ui.js | 587 ++++++++++++-- web/app.js | 739 +++++++++++------- web/display-labels.js | 6 +- web/index.html | 54 +- web/present-shells.js | 17 +- web/questions-ui.js | 54 +- web/settings.css | 220 ++++++ web/settings.html | 51 ++ web/settings.js | 140 ++++ web/shells.html | 2 +- web/shells.js | 4 +- web/styles.css | 376 +++++++-- 95 files changed, 6781 insertions(+), 1344 deletions(-) create mode 100644 src/llm/run-abort.ts create mode 100644 src/preset/markers.ts create mode 100644 src/preset/play-frame.ts create mode 100644 src/preset/probe.ts create mode 100644 src/preset/world-info-pack.ts create mode 100644 src/runtime/driver.ts create mode 100644 src/skills/opening-seal.ts create mode 100644 src/skills/play-turn.ts create mode 100644 tests/driver.test.ts create mode 100644 tests/opening-seal.test.ts create mode 100644 tests/play-frame.test.ts create mode 100644 tests/play-turn.test.ts create mode 100644 tests/preset-probe.test.ts create mode 100644 tests/preset-wrapper.test.ts create mode 100644 tests/run-abort.test.ts create mode 100644 tests/world-info-pack.test.ts diff --git a/docs/briefs/artifact-card-coverage.md b/docs/briefs/artifact-card-coverage.md index 58a25cb..8790495 100644 --- a/docs/briefs/artifact-card-coverage.md +++ b/docs/briefs/artifact-card-coverage.md @@ -12,12 +12,12 @@ | 叙事指南与故事推进 | **专用 mosaic**(风格与写法 \| 推进与决策) | `renderNarrativeBodyHtml` | | 变量设计 | **结构化卡**(真值 / side_effects) | `renderVariableDesignHtml` | | 变量控制上下文 | 通用 fragment | context-fragment | -| 正文组成 | 通用 fragment + 游玩期 **present 壳** | fragment;游玩 `present-shells.js` | +| 正文组成 | **专用卡**:present 壳渲染 `正文.示例.灌数`(看美化)+ 契约折叠 | `renderReplyFormatBodyHtml` | | 设计监控栏 | 通用 fragment | context-fragment | | 游玩拓扑 | **槽位芯片卡** | `renderPlaySlotsHtml` | | 上下文投影排序 | **排序卡** | `renderContextOrderHtml` | | 裁决包 settlement.v1 | **分节卡** | `renderSettlementSectionsHtml` | -| 开场白与开场变量 | 通用 fragment | context-fragment | +| 开场白与开场变量 | 专用:候选 swipe + 呈现壳 | `renderOpeningSetupBodyHtml` | | 拓扑图谱 | (骨架未写) | — | **说明:** 「通用 fragment」= 公共头(brief/mount/自评)+ 正文结构化树,已比生 JSON 友好;「专用」= 按技能排版的 mosaic/卡片。 diff --git a/docs/briefs/capability-authoring-brief.md b/docs/briefs/capability-authoring-brief.md index 447bb85..12ece1a 100644 --- a/docs/briefs/capability-authoring-brief.md +++ b/docs/briefs/capability-authoring-brief.md @@ -121,7 +121,8 @@ "steps": [ { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, { "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, - { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] } + { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }, + { "id": "美学纲领与交互范式·改", "name": "美学纲领与交互范式", "mode": "revise", "revises": "美学纲领与交互范式", "depends_on": ["生成规则#2"] } ] } ``` @@ -133,9 +134,13 @@ | `id` | 本局步骤唯一键;同技能多次必须不同 | | `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) | | `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) | +| `mode` | 可选。缺省 / `fresh` = 从零新建;`revise` = 回头修改,继承 `revises` 所指那步的既有产物 | +| `revises` | `mode=revise` 时必填:被改的既有步骤 id(须更前、同 name) | +| `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) | 程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。 -**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次编入。 +**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改用 `mode=revise`,不要再排一条 fresh。 +〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。 ## 2.5 已合并技能(勿拆回) @@ -326,6 +331,8 @@ boundary: | |------|------| | `declaration` | 插入编排提示,选型 | | `name` / `artifact` | 流程与执行映射 | +| `kind` | 可选;节点特性。`prior-artifact` = 先验产物(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) | +| `params` | 可选;普通节点=编排参数。〔先验产物〕=规划产物字段(可提前填,空则步内钉) | | `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | 改 `name` 会破坏已有工作流计划 JSON,尽量不改。 @@ -462,12 +469,12 @@ artifact: | 美学纲领与交互范式 | `aesthetics-interaction` | 范例 | | 实现机制 | `mechanism` | 已写 | | 舞台骨架 | `world-blueprint` | 已写(`context-fragment.v1`;社会结构 + 世界状况) | -| 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;合同严、正文可长) | -| 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;只按规则执行) | +| 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;合同严、正文可长) | +| 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;只按规则执行) | | 叙事指南与故事推进 | `narrative` | 已写(`context-fragment.v1`;遣词/笔墨/禁忌+推进;旧称叙事指南) | -| 正文组成 | `reply-format` | 已写(用户可见版式+隐藏段+前端拆分;旧称设计回复格式) | +| 正文组成 | `reply-format` | 已写(壳适配 + 示例 present 预览看美化;旧称设计回复格式) | | 设计监控栏 | `status-bar` | 已写(只盯会变信息;旧称设计状态栏) | -| 开场白与开场变量 | `opening-setup` | 已写(开场正文+初值;守正文组成) | +| 开场白与开场变量 | `opening-setup` | 已写(〔收口〕:1~多条开场;选定后落库并保存定稿) | | 拓扑图谱 | `topology` | 待细写 | | 变量设计与更新规则 | `variable-design` | 已写;见 progressive-data-design.md | | 变量控制上下文 | `variable-context` | 已写 | diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index 869f83d..145e79e 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -57,7 +57,7 @@ 生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。 美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。 叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。 -正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。 +正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。 设计监控栏:只监控会变字段(可含攻略目标/场景交互物);artifact=`设计.监控栏`(旧称状态栏)。 变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。 变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。 @@ -82,8 +82,8 @@ order 4: 用户.最新输入(full) ```json { "schema": "context-order.v1", - "brief": "扁平投影序", - "play_slots": { "gm": true, "narrator": true, "perspective": false }, + "brief": "先名册再分配:旁观 + 主世界层 + 转述", + "play_slots": { "auditor": true, "gm": true, "narrator": true, "perspective": false }, "slots": [ { "ref": "world-simulator", @@ -99,6 +99,8 @@ order 4: 用户.最新输入(full) } ``` +创作期 LLM 也可先写 `agents[]`(`id` 为 `auditor` / `gm` / `narrator` / `perspective` / `chance`,含 `enabled` / `when` / `inserts`);程序会收成上表的 `slots` + `play_slots`。运行时仍读 `slots[].ref`(worker ref)。 + | 字段 | 说明 | |------|------| | `order` | 每槽独立扁平数字序;**0 建议为人设** | diff --git a/docs/creation-playbook.md b/docs/creation-playbook.md index 8cdc506..f8f5fb9 100644 --- a/docs/creation-playbook.md +++ b/docs/creation-playbook.md @@ -36,9 +36,11 @@ Book 跨 Session:过程、资产、游玩 新建作品 → UI 选配方 → 用户首句 → design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed) → 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物 - → 不够则再 design-flow(可追加 / 反复调用 repeatable 技能)→ closed + → 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**;已完成节点用回头修改继承旧稿)→ closed → 收成 设计.worker集(运行规格)→ 验收后才可进 play - → (可选)opening-generator → 用户手动进游玩 + → 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance + → (无开场白节点时可选)opening-generator 兜底落库 + → 用户手动进游玩 → run:执行单元按契约从黑板重装;acceptance=review 处停、压缩过程 tag ``` diff --git a/docs/play-presentation-shells.md b/docs/play-presentation-shells.md index bdb050c..d240d62 100644 --- a/docs/play-presentation-shells.md +++ b/docs/play-presentation-shells.md @@ -13,7 +13,7 @@ → 按「要同时看见什么」选 shell_id(不是按好看) → 只改允许的微调轴 → 禁止改分区骨架、禁止发明第八种布局 -游玩期:往壳区域灌数;正文随 600~3000 字撑高滚动 +游玩期:往壳区域灌数;正文随 500~2000 字撑高滚动 ``` 选壳依据(同时可见关系): @@ -36,7 +36,7 @@ | 优先级 | 区域 | 视觉待遇 | |--------|------|----------| -| **P0** | `body` | 阅读主轴;**随字数撑高**(单轮按 600~3000 字设计),由页面/消息区滚动 | +| **P0** | `body` | 阅读主轴;**随字数撑高**(单轮按 500~2000 字设计),由页面/消息区滚动 | | **P1** | 建议行动 | 要决策时醒目;`choice_dock` 坞 **sticky 贴底** | | **P2** | `monitor` | 芯片密排,高度固定矮 | | **P3** | `header` / `footer` / `aside` | 辅助;双栏 **aside sticky**,长文仍可对照 | @@ -88,6 +88,7 @@ 正文.呈现壳.shell_id → 七选一 正文.呈现壳.微调 → 只填允许轴 正文.可见块[] → 挂到壳已有区域 + 正文.示例.灌数 → present.v1 形状预览(非正史;专供验收壳/美化) 设计.监控栏 → monitor 字段表 运行.本轮.裁决 → body 事实;suggested_actions → 行动区 @@ -130,4 +131,4 @@ --- -修订:2026-08-13 · 七壳;明确信息层级与主面积。 +修订:2026-08-22 · 七壳;终稿阅读柱;单轮正文 500~2000 字。 diff --git a/docs/preset-format.md b/docs/preset-format.md index a94b899..5aafc4b 100644 --- a/docs/preset-format.md +++ b/docs/preset-format.md @@ -216,39 +216,72 @@ type UnsupportedPresetSection = { ## 4. 上下文装配规则 -一次请求的上下文装配按以下顺序执行: +运行时只走一份 `promptOrder`:遇到普通条目原样插入,遇到 marker 就填洞。空的酒馆角色卡 / 世界书 marker **不填我们的正文**(跳过)。 + +### 4.1 Marker 词汇表 + +酒馆原有(导入后保留位置,空洞跳过): ```text -1. 读取 PresetPackage.promptOrder。 +worldInfoBefore personaDescription charDescription charPersonality +scenario worldInfoAfter dialogueExamples chatHistory +``` + +我们新增(由 context-order 填值;生成内容按 **世界书条目** 看待,不是角色卡字段): + +| identifier | 填什么 | +|---|---| +| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 | +| `chatHistory` | 共用酒馆 id;填 `对话.历史` | +| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` | +| `currentTurn` | `用户.最新输入`,role=user | +| `postTurn` | 本轮输入之后的 inserts(可空) | + +`worldInfoBefore` / `worldInfoAfter` 都在酒馆历史 **前**,不能承接「我们写的、历史后」,因此不要复用那两个 id。 + +### 4.2 导入时插入四个洞 + +导入 SillyTavern `prompt_order` 时,以 `chatHistory` 为锚点切开并插入: + +```text +[...ST 历史前条目...] +worldBookBefore +chatHistory +worldBookAfter +currentTurn +[...ST 历史后条目...] +postTurn +``` + +若原文没有 `chatHistory`,把它补在历史前条目之后,再接后四个洞。原生预设也可以直接按这份扩展 order 来写。无选用预设时,play worker 仍按现有 `assembleWorkerContext` 拼装(不走 prompt_order)。 + +社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。 + +### 4.3 六段夹心(扫 order 后的效果) + +```text +1. 酒馆内、历史前(ST 普通条目) +2. 我们写的、历史前(worldBookBefore) +3. 对话历史(chatHistory) +4. 我们写的、历史后本轮前(worldBookAfter) +5. 本轮对话(currentTurn) +6. 本轮对话后(ST 历史后条目 + postTurn) +``` + +### 4.4 装配步骤 + +```text +1. 读取(已扩展过的)PresetPackage.promptOrder。 2. 过滤 enabled=false 的顺序项。 3. 找到对应 PresetPromptEntry。 4. 再过滤 entry.enabled=false 的条目。 -5. 对 marker 条目进行运行时替换。 -6. 对普通 content 条目进行变量替换。 -7. 按 role 合并成 LLM messages。 +5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。 +6. 普通 content 条目原样插入。 +7. 按条目 role 合成 LLM messages。 8. 应用 generation 参数。 ``` -其中 marker 条目用于挂载运行时上下文: - -```text -charDescription - 可以映射为当前创作项目的设定说明。 - -charPersonality - 可以映射为文风、叙事人称、角色行为约束。 - -worldInfoBefore / worldInfoAfter - 可以映射为长期设定、世界观、知识库片段。 - -scenario - 可以映射为当前创作目标或当前阶段说明。 - -chatHistory - 可以映射为历史会话、已有正文、用户反馈摘要。 -``` - -这些映射不是 SillyTavern 原义的完整复刻,而是为了兼容预设资产,让它们能服务我们的写作 agent。 +不实现:世界书关键词、概率、sticky、Depth 插进历史中间、outlet、正则、插件脚本。 ## 5. 与创作流程的关系 @@ -270,6 +303,8 @@ Runtime Session 因此,同一个 preset 可以用于多个创作流程;同一个创作流程也可以切换不同 preset。二者是正交关系。 +生成参数可全局合并进 LLM 请求。**prompt 条目 + marker 填洞只在 play worker 上用**:创作步 / 总管 tool loop 不得把角色卡骨架套到 design-step 上,也不得在填洞后再叠一份相同的 user 上下文。 + ## 6. 第一版导入策略 第一版导入器只做保守转换: diff --git a/docs/run-snapshot.md b/docs/run-snapshot.md index 92a37c6..d894b80 100644 --- a/docs/run-snapshot.md +++ b/docs/run-snapshot.md @@ -13,7 +13,7 @@ | kind | 名称 | 何时存 | 存什么 | |------|------|--------|--------| -| **`instance`** | 创作定稿截面 | Worker 集 accept;可选含 **锁定开局** | `设计.worker集`、静态设定 tag、`运行.初始变量`、`输出.开场白`;**不含**后续轮次、事件流 | +| **`instance`** | 创作定稿截面 | Worker 集 accept;**选定开场后自动存**(也可手动) | `设计.worker集`、静态设定 tag、`运行.初始变量`、`输出.开场白`;**不含**后续轮次、事件流 | | **`opening`** | 开局锁定(可选单独存) | 创作末尾 swipe 选定开场后 | 与 instance 中开局部分相同;便于「同 Worker 集、换开局再开 play 线」 | | **`run`** | 游玩存档 | play 任意时刻 | instance/opening 层 + `运行.*`、轮次、变量当前值、对话 | diff --git a/docs/ui-glossary.md b/docs/ui-glossary.md index 64889b9..eb9ba33 100644 --- a/docs/ui-glossary.md +++ b/docs/ui-glossary.md @@ -35,7 +35,7 @@ **一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。 -**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。 +**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改用**回头修改**(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。 **剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成: - **工作流计划** = 怎么排技能步骤(DAG) @@ -100,7 +100,7 @@ |---------|----------|------| | `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) | | `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 | -| `opening-generator` | **开局 · 开场白** | 创作末尾可选 | +| `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 | | `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 | | `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 | @@ -182,7 +182,7 @@ | `intake` | 描述需求 | 发送 | | `input` | 补充说明 | 发送 / 补充并发送 | | `worker_questions` | 答题 | 补充并发送 | -| `approve_step` | 确认 | 同意并开始 | +| `approve_step` | 确认 | 同意并开始;回头修改时文案为「回头修改 · {技能名}」 | | `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 | | `next_intent` | 说话 | 继续(可留空) | | `revision` | 说话 | 按意见修改 | diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md index 24ad0b1..4c24f3c 100644 --- a/docs/world-simulator-modules.md +++ b/docs/world-simulator-modules.md @@ -86,8 +86,11 @@ declaration: … | 字段 | 作用 | |------|------| | `id` / `name` / `declaration` / `artifact` | 索引与产物映射;`declaration` 可被 prompt `meta` 覆盖 | -| `repeatable` | 可选;`true` = 允许同能力多次编入增量 DAG | -| `params` | 可选;编排期 `steps[].params` 声明(`key`/`label`/`required`/`hint`);必填项须在进执行前钉齐 | +| `repeatable` | 可选;`true` = 允许同能力多次**新建**编入增量 DAG(每次彻底新的,不改旧条) | +| `closer` | 可选;`true` = 创作终节点(选定后落库、关 DAG、保存定稿) | +| `auto` | 可选;`true` = 程序步(提案后直接执行) | +| `kind` | 可选;节点特性。缺省=普通执行步。`prior-artifact` = **先验产物**(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) | +| `params` | 可选;普通节点=编排期 `steps[].params` 声明(必填项须在进执行前钉齐)。〔先验产物〕=规划产物字段(可提前填,空则步内钉) | | `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | 编排器注入【能力 · 可选工序】时,会读取各能力 `prompt.md` 的 `meta`(`declaration` / `when` / `when_not` / `boundary`),**不是**只看 catalog 短声明。执行全文仍只在 design-step 注入。 @@ -104,6 +107,15 @@ declaration: … 配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。 +### 节点特性(`kind`) + +节点特性是 catalog 上的固定系统,不是某一步的特例。缺省为普通执行步;后续会加更多 kind。 + +| kind | 中文 | 行为 | +|------|------|------| +| (缺省) | 普通执行步 | 有必填 params 时,确认开干前钉齐 | +| `prior-artifact` | 先验产物 | 须先定「写什么」(生成规则、具体实例)。编排器可提前把规划写入 `params`——规划多条是正确的。执行时注入【规划产物】;规划可空,步内钉/修订。不拦确认开干 | + ### 节点循环(通用) ```text @@ -113,15 +125,21 @@ declaration: … → 用户选选项 / 写意见 →「按意见修改」 (产物 + 追问 + 选项 + 意见 + 节点要求一并重跑)→ 新产物 → 循环直到「接受」 - →「下一步想写什么」(可留空,交给工作流计划 DAG) - → 展示下一节点并确认开干(可在此钉编排参数,如生成规则的 target) + → 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行) + · 可反复技能再来一次 = 彻底新建(新 id / 新 params) + · 已完成节点要改 = 回头修改(mode=revise,继承旧产物) → 下一节点 opening / design-step … -直到创作收口 +直到「开场白与开场变量」(〔收口〕) + → 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿 ``` 「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。 -有编排参数的能力:可在确认开干时钉齐 params;勿把「生成什么」拖到执行中途才问。 +「回头修改」是编排层能力:给已完成步骤追加一条 `mode=revise` 的新步,执行时注入既有产物继续改。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,用回头修改,不必整局重开。 + +〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。 + +有编排参数的**普通**能力:可在确认开干时钉齐 params。 --- @@ -140,15 +158,15 @@ 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`;宽原则 + 严合同键;可反复;`params.target`;挂 gm+auditor;**专用卡已接** | -| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;`params.rule_id` | +| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;宽原则 + 严合同键;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** | +| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` | | 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 | | 拓扑图谱 | `topology` | 骨架,待细写(拟改为选项小循环或降级) | | 设计监控栏 | `status-bar` | **已写**:只盯会变信息;通用 fragment 卡 | -| 正文组成 | `reply-format` | **已写**:壳适配;P2 present 渲染已接 | +| 正文组成 | `reply-format` | **已写**:壳适配 + 正文.`示例`(present.v1 预览,看美化);P2 present 渲染已接 | | 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 | | 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 | -| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值 | +| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值;**〔收口〕**选定后落库并保存定稿 | 共用收成(池内保留,按需): @@ -170,6 +188,6 @@ recipes/world-simulator|expand-assistant/recipe.yaml 1. 只选配方 → 出**近期**创作流程(`status=open`) 2. design-step 能切割出 `opening`/`task`/… 3. 有 `opening` 时先程序开场再 LLM -4. 可追加同能力多次(不同 step.id + params);收成前 `status=closed` -5. UI 用编排术语;有编排参数的步骤须展示 params -6. 缺必填 params 的步骤不得视为可执行(校验失败 / 编排先 askUser) +4. 可追加同能力多次(不同 step.id + params,**彻底新建**);也可 `mode=revise` 回头修改已完成节点(继承旧产物);收成前 `status=closed` +5. UI 用编排术语;有编排参数的步骤须展示 params;回头修改在流程上标「回头修改」,确认开干文案与「接下来生成」区分 +6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;回头修改可继承原步 params diff --git a/skills/dialogue/world-simulator/README.md b/skills/dialogue/world-simulator/README.md index 1c2b863..ac9019f 100644 --- a/skills/dialogue/world-simulator/README.md +++ b/skills/dialogue/world-simulator/README.md @@ -25,7 +25,7 @@ worker-templates/ ```text 选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open) → 反复 design-step(注入 modules/{id}/prompt.md) - → 不够则再 design-flow(可追加 repeatable 能力)→ closed + → 不够则再 design-flow(可追加 repeatable 新建,或 mode=revise 回头修改)→ closed → (可选)opening-generator → 手动进 play ``` diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md index 553ae80..06c2a3f 100644 --- a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -76,28 +76,15 @@ feeds: design_only ## probe ```probe -用户首句或黑板已有的直接采纳。一次只在产物「追问」写 1~2 点(建议选项 + 示例);顶层 askUser 留 null,不要同一问写两遍。 -已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。 +本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。 -【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。 -若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。 +用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉;开场若已跳过,把「用户.需求」首句当答复。已答清的不重问。 -【参与 · 结构性】 -- 用户与 :代入程度 / 情感距离 / 控制期待 / 多角色 -- 输入解释:主视角行动 vs 世界行动;正文人称(第二人称 / 上帝 / 第一人称…) -- 焦点位置:自己 / 人物 / 群像 / 关系 / 世界规则 / 感觉 / 叙事 -- 满足来源:感同身受 / 互动 / 观察 / 掌控 / 创作质量 -完备:能判断用户站哪、镜头看哪、爽从哪来。 +缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。 -【内容 · 深度随焦点】 -核心感觉;人物与关系;身体与感官;背景与规则(宜短);意义与主题。 -变造已定位的,只挖服务核心体验的部分。 +用户讲人物、舞台、规则:只抽取能改这三件的一句写入正文,其余写入「开放问题」,本步不追问细节。 -【轮转】 -思考与抉择、言语:何时等、怎么等、例外、输入后怎么补(只确认/补细节/补心理/连带后果)。 - -【收成三块】 -体验内核(含形状:过程/张力/层次/构成/循环);呈现要点;边界禁忌。 +用两种站位或两种感觉的短场景让用户选或改,不追问开放式「你还想要什么」。 ``` ## output diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml index 64b66d6..bb0409f 100644 --- a/skills/dialogue/world-simulator/modules/catalog.yaml +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -4,8 +4,13 @@ # name = 固定中文名(流程 JSON 的 name;同能力可多次时靠 step.id 区分) # declaration = catalog 短声明(可被 prompt.md ```meta 覆盖) # artifact = 执行期产物 tag(程序映射;不写进流程 JSON) -# repeatable = true 时允许同能力多次编入增量 DAG(如生成规则、具体实例) -# params = 可选;编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐) +# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。 +# 回头修改已完成节点不是 repeatable:DAG 用 mode=revise + revises,继承旧产物。 +# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿 +# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收) +# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行) +# params = 可选;普通节点=编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐) +# 〔先验产物〕=规划产物字段(可提前填,空则步内钉) # opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md) # # 编排选型:程序会读取各 modules/{id}/prompt.md 的 meta @@ -15,8 +20,10 @@ # 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行索引 # # 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。 -# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力。 -# 有编排参数的能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。 +# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。 +# 已完成节点要改:编排 mode=revise(继承旧稿),不要再排一条 fresh。 +# 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。 +# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。 modules: # —— 体验契约 —— @@ -37,6 +44,7 @@ modules: - id: generation-rules name: 生成规则 + kind: prior-artifact repeatable: true declaration: >- 仅当某类内容既重要又无法凭基底稳定生成时:写出规则 @@ -45,19 +53,17 @@ modules: params: - key: target label: 生成对象 - required: true - hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它;用户改对象时必须同步改 rule_id 与步骤 id + hint: 规划字段;编排器已知写哪一类就填。空则步内钉 - key: rule_id label: 规则 id - required: false - hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants,丧尸→zombies);改对象时禁止沿用旧 id + hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants);改对象时禁止沿用旧 id - key: lifecycle_intent label: 生命周期意图 - required: false - hint: runtime_only | seed_only | seed_and_runtime;不明则 askUser + hint: runtime_only | seed_only | seed_and_runtime;不明则步内钉 - id: concrete-instances name: 具体实例 + kind: prior-artifact repeatable: true declaration: >- 按预生成规则原样产出实际产物;不设计、不改合同;可按 rule_id/批次反复调用 @@ -65,15 +71,12 @@ modules: params: - key: rule_id label: 调用规则 - required: true - hint: 必须来自已验收生成规则中 seed_only/seed_and_runtime 的真实 rule_id;编排时给选项 + hint: 规划字段;须来自已验收生成规则中 seed_only/seed_and_runtime 的 rule_id。空则步内钉 - key: batch_goal label: 批次目标 - required: false hint: 本批要锚定什么(如开局重要NPC) - key: count label: 数量 - required: false hint: 数字或「按规则」;contextual 时写清规模依据 - id: narrative @@ -127,9 +130,10 @@ modules: # —— 开局(创作偏晚) —— - id: opening-setup name: 开场白与开场变量 + closer: true declaration: >- - 写出遵循正文组成/叙事等契约的开场一小段,并钉同真相的开场变量初值; - 开场为主、填表为辅;可与 opening-generator 落库 + 创作终节点:写出 1~多条开场白并钉同真相初值; + 选定后程序落库、结束创作并保存定稿 artifact: 设计.开场白与开场变量 # —— 收成 / 钉声明(共用;世界模拟器按需) —— @@ -144,9 +148,10 @@ modules: - id: context-order name: 上下文投影排序 repeatable: false + auto: true declaration: >- - 根据各上游上下文概括,为启用槽写出扁平投影序(含对话.历史与 projection); - 挂谁已定、排第几在本步;不发明执行单元、不重写长文 + 先抄固定执行单元名册,再为每个已启用者写出扁平投影序 + (含对话.历史与 projection);不发明槽、不重写长文、不演情节选择题 artifact: 设计.上下文投影排序 - id: refine diff --git a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md index ac0146e..be70547 100644 --- a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md +++ b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md @@ -27,7 +27,11 @@ feeds: gm ## opening ```opening -规则已由【本步参数】rule_id 钉死。有批次目标或数量偏好可一句带过;否则直接「按规则生成」。 +先认定本批按哪条规则生成: +- 若【规划产物】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。 +- 若规划为空,从已验收生成规则里选出一条预生成规则。 + +有批次目标或数量偏好可一句带过;否则直接「按规则生成」。 ``` ## task @@ -37,9 +41,11 @@ feeds: gm 本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。 +本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【规划产物】当起点;规划可空。 + 执行顺序: -1. 取出 `params.rule_id` 对应规则;非预生成生命周期或缺信息 → 停止并指出缺口。 -2. 数量:`params.count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。 +1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。 +2. 数量:规划 `count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。 3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(单层 JSON;键=schema 顶层字段)。 4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。 5. 输出 JSON;追问通常 `[]`。 @@ -72,9 +78,9 @@ summary:`具体实例 · {对象} · {n}条` "mount": ["world-simulator"], "稳变": "stable", "正文": { - "rule_id": "与 params 一致", + "rule_id": "与本步认定一致(规划有则对齐)", "本批数量": 3, - "批次条件": ["params.batch_goal 等;无则 []"], + "批次条件": ["规划 batch_goal 等;无则 []"], "batch_id": "英文 kebab-case", "records": [ { @@ -111,7 +117,7 @@ summary:`具体实例 · {对象} · {n}条` ## checklist ```checklist -- [ ] 按 params.rule_id 的预生成规则执行? +- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行? - [ ] records 单层、键值符合产物格式与全部约束? - [ ] 自评为合规模?未改合同? ``` diff --git a/skills/dialogue/world-simulator/modules/context-order/prompt.md b/skills/dialogue/world-simulator/modules/context-order/prompt.md index aef944c..24231e6 100644 --- a/skills/dialogue/world-simulator/modules/context-order/prompt.md +++ b/skills/dialogue/world-simulator/modules/context-order/prompt.md @@ -1,8 +1,9 @@ # 上下文投影排序 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 -> **本步只排出扁平投影序,不发明执行单元,不重写设定长文。** -> 「对话.历史」也是可投影标签(改 projection 裁剪长度);见 `docs/context-fragment-design.md`。 +> **先抄程序钉死的执行单元名册,再给每个已启用者分配上下文与顺序。** +> 不发明执行单元,不重写设定长文,不演游玩选项。 +> 「对话.历史」也是可投影标签;见 `docs/context-fragment-design.md`。 ## meta @@ -11,36 +12,32 @@ name: 上下文投影排序 id: context-order artifact: 设计.上下文投影排序 declaration: > - 根据各上游上下文的概括,为每个启用槽写出扁平投影序 - (含对话.历史标签与 projection);不发明槽、不重写正文 + 先列出本局固定执行单元(程序名册),再为每个已启用者写出扁平投影序 + (含对话.历史与 projection);不发明槽、不重写正文、不演情节选择题 when: | 游玩槽已确认(或可按默认),且主要上下文块已有可摘要产物, - 需要决定插入顺序与投影级别、再收成运行规格时。 + 需要决定各执行单元看见什么、以何顺序插入时。 when_not: | 体验/站位仍混沌 → 先美学纲领与交互范式。 槽位未定且用户拒绝默认 → 先游玩拓扑。 上游上下文仍大量空洞、无法判断稳/变 → 先补缺口,勿空排。 只需勾选槽 → 游玩拓扑;只需合并终稿 → 细化终稿。 boundary: | - 本技能:读各块概括 → 产出每槽 inserts[](order / ref / projection / 可选 note);须含对话.历史。 - 无「历史前/后」硬分区;对话.历史本身是可排序的一项。 - 游玩拓扑:槽位权威;本步不增白名单外 ref(含按需 chance 不进每轮序)。 - 中段技能:可带 mount 与稳/变信号,最终 order 以本步为准。 + 本技能:抄名册 → 凭各块 brief/挂载/稳变 给每槽 inserts[]。 + 名册权威在程序(及已勾选的游玩拓扑);本步不增白名单外 id。 + 中段技能可带 mount 与稳/变;最终 order 以本步为准。 细化终稿:把本步排序表收进运行规格 context_order;本步不输出完整 设计.worker集。 + 禁止把依赖产物里的游玩示例(迎上去/推开/反客为主 等)当成当前要问的题。 feeds: design_only ``` ## opening ```opening -准备排出各游玩槽的扁平投影序(只排序,不改设定正文)。 +按已验收产物的概括,给每个固定执行单元分配上下文顺序(只排序,不改设定、不演游玩)。 -请确认或补充: -1. 启用槽是否仍是默认?(世界模拟:主世界层 + 叙事转述;角色视角默认关) -2. 「对话.历史」希望夹在哪、投影多狠?(默认 summary) -3. 有没有「几乎永不改」或「每轮必变」的块要特别强调? - -若前面产物够用,可直接回复「按已有概括排序」。 +名册由程序钉死:旁观维护(表格)/ 主世界层 / 叙事转述 / 可选角色视角 / 按需机遇。 +若前面产物够用,直接回复「按已有概括排序」。 ``` ## task @@ -48,40 +45,45 @@ feeds: design_only ```task 你正在执行「上下文投影排序」。产物写入「设计.上下文投影排序」。 -输入:已启用槽;各上游上下文的短摘要(及 mount / 稳变信号,若有)。 -输出:按槽分组的格式化排序表——Runtime/终稿据此把投影插到固定位置。 +执行顺序(必须按此,禁止颠倒): +1. **抄名册**:以程序注入的【本局固定执行单元】为准列出 agents[](id / label / enabled / when / duty)。 + 启用与否跟已勾选游玩拓扑走;未勾选则按默认:旁观+主世界层+转述开,角色视角关,机遇关。 +2. **再分配**:只对 enabled 的每轮槽写 inserts(order / ref / projection / 可选 note)。 + 凭上游 **brief、挂载、稳变** 排序,禁止把百科全文再读一遍当创作。 规则: -1. 禁止发明执行单元或白名单外 ref。 -2. 禁止重写上游长文;只写 order、ref、projection 与一句 note(勿再写硬锚点分区)。 -3. 每槽扁平数字序:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。 -4. 必须包含一项 ref=`对话.历史`(默认 projection=summary);历史由程序维护,本步只决定夹在哪、投影多狠。 -5. 常见块名用新 artifact:设计.舞台骨架 / 设计.叙事指南与故事推进 / 设计.正文组成 / 设计.监控栏 等。 +1. 禁止发明白名单外 id(含画图等未入册槽)。 +2. 禁止重写上游长文;只写顺序与投影。 +3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。 +4. 主世界层与转述必须含 ref=`对话.历史`(默认 projection=summary);旁观维护默认不含历史。 +5. 按需槽(机遇)enabled 时 inserts 可空。 6. 越稳越靠前;本轮输入/真值/裁决通常在历史之后。 -7. 同一内容可挂多槽;chance 为按需槽,不必排进每轮 inserts。 -8. summary:如 `投影排序 · 主世界层 N 条 · 含历史`。 +7. 同一内容可挂多槽;chance 不必排进每轮 inserts。 +8. summary:如 `投影排序 · 旁观+主世界层+转述`。 -若程序已发默认问题:禁止重复同一开场;在首答上产出表。 +若程序已发默认问题:禁止重复同一开场;用户说「按已有概括排序」或等价同意 → **立刻产出表**,禁止再问情节内选项。 +禁止 askUser 出游玩选择题(迎上去/推开她/反客为主 等)。材料已齐则顶层 askUser 为 null。 ``` ## principles ```principles -1. 晚排序:此时才能区分常变与重要;勿回退去发明新槽。 -2. 概括驱动:凭摘要与稳变信号排序,不把百科全文再读一遍当创作。 -3. 历史是标签:用投影裁剪,不要假装「整段历史永远在中间锚点」。 -4. 缓存优先:少变块靠前;高频变块靠后。 -5. 投影分级:full / summary / fields / fixed——历史默认 summary。 -6. 可修订已有排序表;用户改稳变判断时只改序,不改上游产物 id。 +1. 名册先行:先有哪些执行单元,再谈每单元看什么。 +2. 晚排序:此时才能区分常变与重要;勿回退去发明新槽。 +3. 概括驱动:凭摘要与稳变信号排序,不把设定长文再当创作。 +4. 历史是标签:用投影裁剪;旁观维护默认不进对话.历史。 +5. 缓存优先:少变块靠前;高频变块靠后。 +6. 投影分级:full / summary / fields / fixed——历史默认 summary。 +7. 可修订已有排序表;用户改稳变判断时只改序,不改上游产物 id。 ``` ## probe ```probe -一次 askUser 1~2 点,优先 options: -- 某块稳/变判断冲突时,问用户更偏「少改利缓存」还是「每轮必须看见」。 +一次 askUser 1~2 点,且必须关于「挂哪槽 / 稳还是变 / 投影级别」: +- 某块稳/变判断冲突时,问更偏「少改利缓存」还是「每轮必须看见」。 - 角色视角若开启,问哪些块禁止进主世界层。 -已齐则直接产出,勿为凑问题而问。 +已齐则直接产出,勿为凑问题而问。禁止问剧情里发生什么、谁敲门、玩家选哪一招。 ``` ## output @@ -89,29 +91,64 @@ feeds: design_only ```output { "schema": "context-order.v1", - "brief": "一句话:本局扁平投影序(含对话.历史)", - "play_slots": { "gm": true, "narrator": true, "perspective": false }, - "slots": [ + "brief": "一句话:本局有哪些执行单元、各自上下文怎么排", + "agents": [ { - "ref": "world-simulator", - "label": "主世界层", + "id": "auditor", + "label": "旁观维护", + "enabled": true, + "when": "every_turn", + "duty": "查表/补规则,不写正文、不写真相", "inserts": [ - { "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "槽位人设" }, - { "order": 1, "ref": "设计.实现机制", "projection": "summary", "note": "承重规则摘要" }, - { "order": 2, "ref": "对话.历史", "projection": "summary", "note": "按投影裁剪" }, - { "order": 3, "ref": "变量.当前", "projection": "fields", "note": "真值" }, - { "order": 4, "ref": "用户.最新输入", "projection": "full", "note": "本轮" } + { "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "人设" }, + { "order": 1, "ref": "设计.变量设计与更新规则", "projection": "summary" }, + { "order": 2, "ref": "设计.生成规则", "projection": "summary" }, + { "order": 3, "ref": "变量.当前", "projection": "fields" }, + { "order": 4, "ref": "用户.最新输入", "projection": "full" } ] }, { - "ref": "narrator", - "label": "叙事转述", + "id": "gm", + "label": "主世界层", + "enabled": true, + "when": "every_turn", + "duty": "读真值与规则,出裁决包", "inserts": [ - { "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "转述人设" }, + { "order": 0, "ref": "worker.persona", "projection": "fixed" }, + { "order": 1, "ref": "设计.舞台骨架", "projection": "summary" }, + { "order": 2, "ref": "对话.历史", "projection": "summary" }, + { "order": 3, "ref": "变量.当前", "projection": "fields" }, + { "order": 4, "ref": "用户.最新输入", "projection": "full" } + ] + }, + { + "id": "narrator", + "label": "叙事转述", + "enabled": true, + "when": "every_turn", + "duty": "只把裁决写成用户可见正文", + "inserts": [ + { "order": 0, "ref": "worker.persona", "projection": "fixed" }, { "order": 1, "ref": "设计.叙事指南与故事推进", "projection": "summary" }, { "order": 2, "ref": "对话.历史", "projection": "summary" }, - { "order": 3, "ref": "运行.本轮.裁决", "projection": "full", "note": "settlement" } + { "order": 3, "ref": "运行.本轮.裁决", "projection": "full" } ] + }, + { + "id": "perspective", + "label": "角色视角", + "enabled": false, + "when": "every_turn", + "duty": "强信息隔离时出反应建议", + "inserts": [] + }, + { + "id": "chance", + "label": "机遇裁定", + "enabled": false, + "when": "on_demand", + "duty": "骰子/抽签;按需调用,不进每轮", + "inserts": [] } ] } @@ -120,10 +157,12 @@ feeds: design_only ## checklist ```checklist +- [ ] agents 只使用程序名册 id(auditor / gm / narrator / perspective / chance) - [ ] 未发明白名单外执行单元 - [ ] 未重写上游长文,只有排序/投影 -- [ ] 每启用槽有 order 0 人设位 -- [ ] 每槽含「对话.历史」且有合理 projection +- [ ] 未把游玩选项示例当成当前提问 +- [ ] 已启用的每轮槽有 order 0 人设位 +- [ ] 主世界层与转述含「对话.历史」;旁观维护默认无历史 - [ ] 少变块整体靠前、本轮/真值靠后(软规则) - [ ] 与游玩拓扑勾选一致 ``` diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md index 6948672..5a3311c 100644 --- a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -36,8 +36,11 @@ feeds: gm,auditor ## opening ```opening -本轮生成对象已由编排参数钉死(见【本步参数】target)。请直接谈该类内容: +先认定本轮规则写哪一类内容: +- 若【规划产物】已有对象,以此为起点;要改对象就在本步改。 +- 若规划为空,先钉对象族,再谈规则。 +然后谈: 1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果? 2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成? 3. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有) @@ -56,19 +59,19 @@ feeds: gm,auditor 3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举; 一条规则可多个池;无则 `池: []`。 -对象族已由【本步参数】`target` 钉死;禁止再问「这一步生成什么」,禁止顺手扩到其它族。 +本步是〔先验产物〕:先定本轮对象族,再写规则。读【规划产物】当起点;规划可空。禁止顺手扩到其它族。 执行顺序: -1. 读 params 与依赖。缺 `target` → 停止并提示补参。 +1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。 2. 双门槛:重要性 + 基础上下文不足。任一项不成立 → 结论「无需专门规则」,`rules: []`。 -3. 两项成立时,生命周期三选一(params.lifecycle_intent 合理则采用): +3. 两项成立时,生命周期三选一(规划里 `lifecycle_intent` 合理则采用): - `runtime_only`:仅游玩期动态生成;不要求「具体实例」。 - `seed_only`:由「具体实例」预生成;规则用完可丢。 - `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。 4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。 5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。 -6. `rule_id`:params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 - 若本步对象与 params 不一致(用户中途改了对象),以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。 +6. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 + 若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。 7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。 若程序已发默认问题:禁止重复开场;在首答上补洞。 @@ -93,7 +96,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生 ## probe ```probe -每轮 1~2 点。禁止重问 params 已钉的 target / rule_id。 +每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。 优先:整条规则是否必要;某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。 若双门槛不成立 → 建议跳过,停追问字段与格式。 @@ -111,9 +114,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生 "稳变": "stable", "正文": { "本步参数": { - "target": "与 params 一致", + "target": "与本步认定的对象一致(规划有则对齐,步内改则以最新为准)", "rule_id": "已钉或本步拟定;跳过时可空字符串", - "lifecycle_intent": "params 原值或空" + "lifecycle_intent": "规划原值、本步钉死值或空" }, "必要性判断": { "生成对象": "本轮对象族", diff --git a/skills/dialogue/world-simulator/modules/narrative/prompt.md b/skills/dialogue/world-simulator/modules/narrative/prompt.md index cea98fa..c5f114d 100644 --- a/skills/dialogue/world-simulator/modules/narrative/prompt.md +++ b/skills/dialogue/world-simulator/modules/narrative/prompt.md @@ -32,7 +32,7 @@ feeds: narrator,gm 美学里的感觉已有方向时,请谈「正文怎么写、故事怎么往前」(想到什么写什么): 1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写? -2. 故事怎么推进?要不要跟好感/进度挂钩?有没有情境要换一套写法? +2. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进?正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。 ``` ## task @@ -45,9 +45,10 @@ mount:`narrator` 与 `world-simulator` 都挂同一份全文——不要为省 执行顺序: 1. 继承美学体验与禁忌,改写成做法向句子。 -2. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好;需要时写情境备用。 -3. 写推进与决策(可写「无专门推进」);变量只写钩子。 -4. 自评 + 追问。 +2. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好。 +3. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。 +4. 写推进与决策(可写「无专门推进」);变量只写钩子。 +5. 自评 + 追问。 summary:`叙事指南与故事推进 · …` ``` @@ -58,14 +59,21 @@ summary:`叙事指南与故事推进 · …` 1. 感觉 vs 做法:美学写要什么爽;本步写怎么遣词、笔墨落哪、故事怎么推。 2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。 3. 推进可轻可无;表规格交给变量/生成规则。 -4. 整份双挂即可;禁止拆成两个技能。 +4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 +5. 整份双挂即可;禁止拆成两个技能。 ``` ## probe ```probe -每轮 1~2 点。已在美学写清的感觉勿重问。 -优先:体裁遣词、笔墨详谁、禁忌、推进门控。 +本步只钉四件:遣词可执行、笔墨落点、绝对禁忌、推进方式(场面推进 / 用户决策 / 无专门推进)。 + +已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。 + +缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。四件够验收:`追问.题目` 为 `[]`。推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。 + +用户谈到变量增减、生成规则、舞台百科:只抽取能改遣词 / 禁忌 / 推进方式的一句,其余写入「开放问题」。 +用户提到要换写法:收成写法档写入「情境备用」(官能 / 爽文 / 恐怖 / 情色这类),不追问具体场合怎么摆;没提则 `[]`。 ``` ## output @@ -99,7 +107,16 @@ summary:`叙事指南与故事推进 · …` "绝对禁忌": ["…"], "不偏好": ["…"] }, - "情境备用": [], + "情境备用": [ + { + "名字": "展示情色", + "用法": "切到身体与权力交换时:感官词加重、节奏放慢、对话变短;切回日常则收。" + }, + { + "名字": "网文爽文", + "用法": "打脸/反转时:可复述对方原话、结果写满、过程可略。" + } + ], "推进与决策": { "有无专门推进": "有|无", "驱动力来源": "…", @@ -126,18 +143,20 @@ summary:`叙事指南与故事推进 · …` ``` 硬规则:合法 JSON;示范至少 1 段;禁忌与不偏好都要有;双挂;禁止 order;summary 见 task。 +情境备用若有:名字必须是写法档(官能小说 / 网文爽文 / 展示恐怖 / 展示情色 同级),用法写该档的遣词与切入口;不是「当众折辱」「物资告急」这类具体桥段。无切换则 `[]`。 ## checklist ```checklist - [ ] 一份产物同时服务转述与主世界层? - [ ] 遣词+示范+禁忌够用?推进清楚或明确无? +- [ ] 情境备用若有,是否是写法档(官能/爽文/恐怖/情色同级)而非具体桥段?无切换是否为 `[]`? - [ ] 未为省 token 拆两套投影? ``` ## examples ```examples -好:一次问清爽文遣词 + 好感门控倒追,整份挂两边。 -坏:拆成「文风技能」和「推进技能」两步;或为省 token 给两边裁成两套残缺稿。 +好:一次钉清遣词 + 笔墨落点 + 禁忌;推进写「等用户决策」或「无专门推进」;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。 +坏:拆成「文风技能」和「推进技能」两步;追问好感公式 / 事件池;或把「当众折辱」「独处一室」当成情境备用。 ``` diff --git a/skills/dialogue/world-simulator/modules/opening-setup/prompt.md b/skills/dialogue/world-simulator/modules/opening-setup/prompt.md index 303433a..7a3c434 100644 --- a/skills/dialogue/world-simulator/modules/opening-setup/prompt.md +++ b/skills/dialogue/world-simulator/modules/opening-setup/prompt.md @@ -2,8 +2,8 @@ > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 产物外壳:`docs/context-fragment-design.md`。 -> 创作偏晚:规格与呈现契约已齐,写「迈进世界的第一拍」并钉开场真值。 -> 磁盘 worker `opening-generator` 可执行/落库本产物到 `输出.开场白` 与 `运行.初始变量`。 +> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场白供选定;选定后程序落库、关闭工作流、保存定稿。 +> 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。 ## meta @@ -12,20 +12,21 @@ name: 开场白与开场变量 id: opening-setup artifact: 设计.开场白与开场变量 declaration: > - 写出遵循正文组成/叙事/监控栏等契约的开场一小段,并钉与之同真相的开场变量初值; - 开场为主、填表为辅 + 创作终节点:写出 1~多条遵循正文组成/叙事/监控栏契约的开场白,并钉与之同真相的开场变量初值; + 选定后程序落库、结束创作并保存定稿 when: | - 体验、舞台、叙事、变量(若有)、正文组成等已大致可引用, - 需要可开玩的第一段剧情与初始真值快照时;通常在细化终稿前后、进游玩前。 + 体验、舞台、叙事、变量(若有)、正文组成、细化终稿等已大致可引用, + 需要可开玩的第一段剧情与初始真值快照时;排在收成链之后,作为最后一步。 when_not: | 体验/呈现仍混沌 → 先美学与正文组成等。 只要改文风不要开场 → 叙事指南。 只要改真值规则不要开场文 → 变量设计。 + 尚未细化终稿、Worker 集未齐 → 先收成。 boundary: | - 本技能:开场正文(按正文组成块序拼出用户可见第一屏)+ 开场变量初值 + 可选监控栏初值示意。 + 本技能是创作终节点:1~多条开场正文(按正文组成块序)+ 开场变量初值 + 可选监控栏初值示意。 必须遵守已验收的叙事指南、正文组成、监控栏、变量设计;不重做这些契约。 具体实例:开场可引用已有实例名片,不在本步新造百科。 - opening-generator:可将本产物写入 输出.开场白 / 运行.初始变量 / 变量.当前。 + 选定后由程序写入 输出.开场白 / 运行.初始变量 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。 feeds: narrator ``` @@ -42,19 +43,20 @@ feeds: narrator ## task ```task -你正在执行「开场白与开场变量」。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。 +你正在执行「开场白与开场变量」。这是**创作最后一步**。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。 -主产物是**开场一小段剧情**(用户第一屏),不是填表工具。 +主产物是 **1~多条开场剧情**(用户第一屏),不是填表工具。用户将选定其中一条;选定后程序结束创作并保存。 必须遵循:叙事指南与故事推进、正文组成(可见块序、隐藏段若开场就要写)、监控栏字段(若启用)、变量真值名单。 执行顺序: 1. 读依赖:worker 集/美学/叙事/正文组成/监控栏/变量设计/舞台与实例。 -2. 按正文组成的可见块写出开场各块内容(至少正文块);隐藏段仅当开场即需声明初值变更时使用(通常初值走「开场变量」字段,隐藏段可空)。 -3. 钉开场变量:与开场事实同一真相;能从设定推断的填上;冲突则以开场叙述为准并改表。 -4. 若有监控栏:给出开场时监控栏展示快照(短)。 -5. 自评 + 追问;输出 JSON。 +2. 写出 **1~3 条**可开玩的开场白候选(角度/第一拍可不同,设定必须同一真相)。推荐那条写入 `开场白全文`,全部写入 `开场白候选`。 +3. 按正文组成的可见块写出开场各块内容(至少正文块);隐藏段仅当开场即需声明初值变更时使用(通常初值走「开场变量」字段,隐藏段可空)。 +4. 钉开场变量:与开场事实同一真相;能从设定推断的填上;冲突则以开场叙述为准并改表。 +5. 若有监控栏:给出开场时监控栏展示快照(短)。 +6. 自评 + 追问;输出 JSON。 -禁止:先问卷填表再糊开场;禁止开场与初值打架;禁止重写 Worker 集。 +禁止:先问卷填表再糊开场;禁止开场与初值打架;禁止重写 Worker 集;禁止只交空壳等下一节点。 summary:`开场白与开场变量 · …`(点题场景)。 ``` @@ -95,7 +97,12 @@ summary:`开场白与开场变量 · …`(点题场景)。 "内容": "该块开场文本;可空块写空字符串并说明为何首轮空" } ], - "开场白全文": "按块序拼好的用户可见开场(主阅读件)", + "开场白全文": "推荐那条:按块序拼好的用户可见开场(主阅读件)", + "开场白候选": [ + "推荐那条(与开场白全文相同)", + "可选第二条:同一真相、不同第一拍/角度", + "可选第三条" + ], "开场变量": [ { "名": "与变量设计真值名一致", @@ -153,13 +160,14 @@ summary:`开场白与开场变量 · …`(点题场景)。 硬规则: 1. context-fragment.v1;自评契约符合/同真相/可开玩。 -2. `开场白全文` 与 `开场变量` 必填;监控栏快照无监控栏则为 `[]`。 +2. `开场白全文` 与 `开场变量` 必填;`开场白候选` 1~3 条(含推荐);监控栏快照无监控栏则为 `[]`。 3. 不输出完整 worker 集;不发明新真值名(须来自变量设计,若无变量设计则可极少数字段并标明临时)。 4. summary:`开场白与开场变量 · {brief 缩略}`。 ## checklist ```checklist +- [ ] 写出 1~3 条开场候选,推荐条与 `开场白全文` 一致? - [ ] 开场遵循正文组成块序与叙事口径? - [ ] 变量与开场同真相?可行动空间清楚? - [ ] 未剧透隐藏档?未写成第一章全文? diff --git a/skills/dialogue/world-simulator/modules/reply-format/prompt.md b/skills/dialogue/world-simulator/modules/reply-format/prompt.md index 4dae2da..e2b3848 100644 --- a/skills/dialogue/world-simulator/modules/reply-format/prompt.md +++ b/skills/dialogue/world-simulator/modules/reply-format/prompt.md @@ -45,7 +45,8 @@ feeds: narrator 1. 要微调什么?(监控字段、块称呼、要不要建议行动、tone_chrome…) 2. 有没有隐藏段给模型/程序维护变量? -3. 不要谈「再发明一种全新布局」。 +3. 定稿时会用前面情节架空一幕灌进壳里,专供你看版式/美化(内容随便,不当正史)。 +4. 不要谈「再发明一种全新布局」。 ``` ## task @@ -64,7 +65,8 @@ feeds: narrator 4. 监控栏:字段意图(细则可交「设计监控栏」);无 monitor 的壳写「不启用」。 5. 隐藏段(若有):定界与用途。 6. 填「呈现壳.微调」;前端拆分区域必须与壳一致。 -7. 自评 + 追问;禁止写 context `order`。 +7. **示例**:用前面已定情节架空一幕,按本步壳与可见块灌进 `present.v1` 形状,专供验收版式/美化;示例文可短,游玩正文字数不在本步打磨。 +8. 自评 + 追问;禁止写 context `order`。 summary:`正文组成 · 适配 {shell_id} · …`。 ``` @@ -73,7 +75,8 @@ summary:`正文组成 · 适配 {shell_id} · …`。 ```principles 1. 创作 = 选壳 + 适配微调 - 不是 UI 创意;布局骨架只读。 + 不是 UI 创意;布局骨架只读。本步验收重点是「看起来对不对」,不是故事写得好不好。 + 「按意见修改」:以最新产物为底;用户要求按先后都有效,冲突时以较新的为准。用户说整体可以、或只提美化/行距/显示名 → 不换 shell_id。换壳必须用户点名。 2. 只改微调轴 字段、显示名、开关、tone_chrome、空态;不改分区、不增区域、不自造壳。 @@ -85,6 +88,11 @@ summary:`正文组成 · 适配 {shell_id} · …`。 5. 与转述、变量分工 文风归叙事指南;真值归变量设计;本步只交壳适配契约。 + 游玩单轮用户主读正文默认 500~2000 字,写进「拼装与终稿.单轮正文篇幅」,供转述遵守。 + +6. 示例 = 壳与美化预览,非正史 + 用已定材料随便架空一幕,把各区域/监控/行动坞撑满,让用户看见版式。 + 禁止把示例当成开场白或游玩 canon;禁止为写好看而新造设定;文笔及格即可,勿打磨成章。 ``` ## probe @@ -92,8 +100,8 @@ summary:`正文组成 · 适配 {shell_id} · …`。 ```probe 每轮 1~2 点。 -优先:选哪个壳;微调哪些字段/显示名;隐藏段要不要。 -不追问:新布局、完整 CSS、事件池 schema、文风遣词。 +优先:选哪个壳;微调哪些字段/显示名;隐藏段要不要;示例灌数是否让版式看得清。 +不追问:新布局、完整 CSS、事件池 schema、文风遣词、示例文笔好坏。 ``` ## output @@ -130,7 +138,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。 "可空": true, "填充方": "叙事转述|程序拼装|监控栏|其它", "内容形态": "散文|短列表|键值行|其它", - "示例": "可选一句/三行示意" + "示例": "可选:该块一句占位(总预览见正文.示例)" } ], "监控栏锚点": { @@ -172,7 +180,27 @@ summary:`正文组成 · 适配 {shell_id} · …`。 "终稿tag": "通常 输出.用户展示", "拼装方": "叙事转述一次写出各块|程序按块拼接|混合", "推荐灌数": "分块壳优先 present.v1;prose 可为纯 Markdown", + "单轮正文篇幅": "默认 500~2000 字(汉字,写入 body);短于 500 仅当用户明确要求极短", "与裁决包关系": "转述读 settlement 再填各块;隐藏段可含 variable_changes 镜像" + }, + "示例": { + "性质": "验收预览|非正史|仅看壳与美化", + "依据": ["前面已定情节/设定要点,一两句即可"], + "用来说明": "这幕只为撑开版式(分区、监控、行动坞、显示名…),内容可糙", + "灌数": { + "schema": "present.v1", + "shell": "与正文.呈现壳.shell_id 一致", + "blocks": { + "monitor": "若该壳有 monitor:短键值;否则省略", + "header": "若有则短字;无则省略", + "body": "架空主读文(短段即可,勿写成章)", + "aside": "若有则短线索/备注;无则省略", + "footer": "若有则可空或一句" + }, + "meta": { + "suggested_actions": ["若该壳开建议行动则给 2~3 个占位选项;否则 []"] + } + } } }, "自评": { @@ -191,6 +219,11 @@ summary:`正文组成 · 适配 {shell_id} · …`。 "名": "可实现", "分数": 8, "说明": "shell_id 是否在七种壳内;分区与隐藏段是否可交给转述/程序" + }, + { + "名": "版式可感", + "分数": 8, + "说明": "示例灌数是否按壳撑开各区,使人一眼能验收美化(不论文笔)" } ], "薄弱点": "一句话" @@ -213,10 +246,12 @@ summary:`正文组成 · 适配 {shell_id} · …`。 1. 合法 JSON;含公共三段外壳。 2. 正文须含 `呈现壳.shell_id`(七种之一)与 `微调`;可见块只挂壳已有区域。 3. `可见块` 至少 1 条(通常含 body);禁止自造区域 id / 第五种壳。 -4. 禁止程序 API schema、context `order`、完整 CSS。 -5. 自评:可见完备 / 监控克制 / 可实现(=壳适配是否可执行)。 -6. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。 -7. 兼容旧称产物 tag「设计.回复格式」。 +4. 正文须含 `示例`:`性质` 标明非正史;`灌数` 为 `present.v1`,`shell` 与本步 shell_id 一致;只填该壳已有区域。 +5. 示例内容可糙:只为验收壳与美化;禁止当开场白/正史;禁止为示例新造设定。 +6. 禁止程序 API schema、context `order`、完整 CSS。 +7. 自评:可见完备 / 监控克制 / 可实现 / 版式可感。 +8. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。 +9. 兼容旧称产物 tag「设计.回复格式」。 ## checklist @@ -225,18 +260,22 @@ summary:`正文组成 · 适配 {shell_id} · …`。 - [ ] shell_id 在七种壳内?可见块未超出该壳分区? - [ ] 微调轴已填或明确默认?监控栏未塞死名片字段? - [ ] 隐藏段(若有)定界清楚?未写 CSS 工程? +- [ ] 有示例灌数(present.v1),壳一致、各区撑开,且标明非正史? +- [ ] 未把示例写成开场白或打磨成长文? ``` ## examples ```examples 好: -- 适配 chat_monitor:微调监控字段=好感/今日话题;body 显示名=「短信」。 -- 适配 chapter_reader:只开 header+body;aside 关。 -- 适配 turn_panel:开 suggested_actions;监控只盯体力/好感。 +- 适配 chat_monitor:微调监控字段=好感/今日话题;body 显示名=「短信」;示例灌数把气泡区+监控撑开,文笔随便。 +- 适配 chapter_reader:只开 header+body;aside 关;示例给短章头+一段 body。 +- 适配 turn_panel:开 suggested_actions;监控只盯体力/好感;示例 meta 带 2~3 个占位行动。 坏: - 「我们自定义一种左右分栏新界面」。 - 不写 shell_id,只写「要好看的 UI」。 - 监控栏列出姓名、性别等死设定。 +- 示例写成第一章正史,或为写好看新造未定设定。 +- 示例 shell 与呈现壳不一致,或灌了壳没有的区域。 ``` diff --git a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml index 9347646..1d08146 100644 --- a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml @@ -21,8 +21,8 @@ process: - 需要门控/分档/程序事件:先「变量设计与更新规则」,再「变量控制上下文」。变量与 Progressive 不是执行单元。 - 挂载归属(非顺序):世界/机制/生成 → 主世界层;叙事指南与故事推进 → 叙事转述(推进兼挂主世界层);美学进常驻。勿为世界观或性格表另开槽。拓扑图谱在固定槽已够用时通常跳过。 - 需要把美学「感觉」落成遣词/笔墨焦点/禁忌与推进时用「叙事指南与故事推进」;事件表 schema 仍用生成规则,真值门控用变量设计。 - - 收成链(顺序固定):上游上下文大致齐 →「上下文投影排序」→「细化终稿」→ status: closed。禁止在排序步发明 ref 或重写长文。 - - 进游玩前可排「开场白与开场变量」(遵循正文组成/叙事/变量);再由 opening-generator 落库到输出.开场白与运行.初始变量。 + - 收成链(顺序固定):上游上下文大致齐 →「上下文投影排序」→「细化终稿」→ 需要开场则排「开场白与开场变量」(〔收口〕:选定后结束创作并保存)。 + - 「开场白与开场变量」写出 1~多条开场白;选定后程序落库到输出.开场白与运行.初始变量,不必再调 opening-generator。 - 变量:真值 + Data 映射索引(实例表在具体实例)+ 维护语句(裁决包/隐藏段,非 chance)+ 旁观汇总挂载(变量控制上下文)。 principles: diff --git a/skills/dialogue/world-simulator/worker-templates/narrator.yaml b/skills/dialogue/world-simulator/worker-templates/narrator.yaml index 90f5b7f..85ecfc0 100644 --- a/skills/dialogue/world-simulator/worker-templates/narrator.yaml +++ b/skills/dialogue/world-simulator/worker-templates/narrator.yaml @@ -39,5 +39,7 @@ prompt_excerpt: | "meta": { "suggested_actions": ["…"] } } monitor 用短键值(芯片),勿写长文。只填该壳已有区域。 + 用户主读正文(body)默认 500~2000 字(汉字):写成可连读的场面/对话推进,不要几句短信就结束。 + 禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。 禁止写出 do_not_say;禁止编造包中没有的事实。 若裁决含 tone_hint / npc_moves / suggested_actions,须体现在 body 或 meta。 diff --git a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md index e79b5f0..ffdac21 100644 --- a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md @@ -34,19 +34,19 @@ contextSegments: label: "## 【用户已选配方】只读引用" - id: user-demand tier: dynamic - tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"] + tags: ["用户.需求", "book.brief", "用户.worker答复"] label: "## 用户表述" --- # 创作 · 流程编排 -你只做一件事:根据用户表述,编排或**增量修订**一份剧本流程(可变 DAG)。 +你只做一件事:根据【用户要求】编排或**增量修订**一份剧本流程(可变 DAG)。要求按先后都有效,冲突时以较新的为准。 上下文里会有: 1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。 2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方 -3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入 +3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次**新建**编入;已完成节点要改用回头修改(mode=revise) 4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。 @@ -59,11 +59,14 @@ contextSegments: - **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。 - **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。 -- 有「编排参数」声明的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params(用户在「下一步」意向后、点同意开始时补齐)。 -- 参数不明时:可先产出带空 params 的步骤,或 askUser;禁止把「生成什么 / 调用哪个规则」拖到 design-step 执行中途。 -- 用户先认可**下一节点**(含参数),再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 +- 〔先验产物〕(生成规则、具体实例):可提前规划「写什么」写入 params;执行时程序注入【规划产物】。规划可空——对象在步内钉。提前规划多条(不同对象/批次)是正确的。**禁止**为钉「生成什么 / 调用哪个规则」而 askUser 拦在确认开干前。 +- 其它有「编排参数」的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params。 +- 用户先认可**下一节点**,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 -同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。 +同能力**可以**多次出现。必须分清两种: + +- **彻底新建**(〔可反复〕如生成规则、具体实例):`mode` 缺省或 `"fresh"`,新 id + 新 params。每一次都是新对象/新批次,**不要**改旧条。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。 +- **回头修改**:用户要改**已经做过**的节点(漏了、谈浅了、后来发现要改)→ 追加新 id,`"mode": "revise"`,`"revises": "<原步 id>"`。程序会注入既有产物,在旧稿上改。非反复技能**禁止**再排一条 fresh;只能 revise。插在最近已验收步之后、尚未执行的步之前。 ## 产出(唯一) @@ -94,6 +97,13 @@ contextSegments: "count": "按规则" }, "depends_on": ["生成规则·重要NPC"] + }, + { + "id": "美学纲领与交互范式·改", + "name": "美学纲领与交互范式", + "mode": "revise", + "revises": "美学纲领与交互范式", + "depends_on": ["实现机制"] } ] } @@ -103,25 +113,28 @@ contextSegments: 1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤) 2. `name` = 能力池里的固定中文名(可重复) -3. `id` = 本局步骤唯一键;同 name 多次时必须不同;优先语义化(`生成规则·{对象}`) +3. `id` = 本局步骤唯一键;同 name 多次时必须不同;新建优先语义化(`生成规则·{对象}`);回头修改用 `{原id}·改` 4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name) -5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写 +5. `params` = 本步调用参数(对象)。〔先验产物〕:此处是规划产物(可空;已知对象就填,便于区分多条)。其它能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写。回头修改可省略,程序会从 `revises` 那步继承 6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成) 7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params -8. 已完成的**非反复**技能(美学纲领、实现机制、舞台骨架、叙事指南等)禁止再排一次执行;只有目录标了〔可反复〕的(生成规则、具体实例等)才可再编入新 id +8. 已完成的**非反复**技能禁止再排一条 `mode=fresh`;要改旧稿只能追加 `mode=revise` + `revises`。〔可反复〕再编入新 id 且 fresh = 彻底新建(新对象/新批次),不是改旧条;改旧条同样用 revise 9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满 10. **禁止**把「交互」与「美学」拆成两步 11. **禁止**在本步写能力正文、自由 Worker 列表、表结构 -12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order -13. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。 - 只许问**排布层**的事:下一步排哪个能力、某步的必填 params 取什么值。 +12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」→(可开玩时)「开场白与开场变量」。挂谁可早、排第几须晚,勿让中段技能写死 order +13. 「开场白与开场变量」标〔收口〕:排在细化终稿之后作为最后一步;排入后不要再追加其它步,可将 `status` 设为 `closed`。选定开场由程序落库并保存定稿,不必再调 opening-generator +14. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。 + 只许问**排布层**的事:下一步排哪个能力、普通节点的必填 params 取什么值、是新建还是回头修改哪一步。 + 〔先验产物〕的「生成什么 / 调用哪个规则」在步内钉,**不要**为此 askUser。 **禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活; 已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」) -14. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选 -15. `summary`:`流程 · N 步 · open|closed · …` -16. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时,须同步改 +15. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选 +16. `summary`:`流程 · N 步 · open|closed · …` +17. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时(编排修订或步内改规划),须同步改 `params.target`、`params.rule_id`(对应英文 kebab-case,如 `zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。 **禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。 + 这是**改这条规则的对象**(回头修改或改未验收步),与「再来一条新的生成规则」不是一回事。 ## 自检 @@ -130,10 +143,12 @@ contextSegments: - 是否误排了多次「发明 Worker」?应改为至多一次「游玩拓扑」 - 每步是否按能力 meta 的何时用/不用判断过,而非题材默认? - 每步 name 都在【能力】里?同名多次是否都有不同 id? -- 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳? +- 有编排参数的普通步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳? +- 〔先验产物〕是否误把「生成什么」当成确认开干前必填?规划可空;已知对象才写入 params - depends_on 是否都指向更靠前的步骤 id? - 已验收 id 是否都还在? -- 【流程进度】里已完成的非反复技能,是否误当成新规划又排了一次? -- askUser 是否只问了排哪步 / 填哪个参数,而不是体验偏好等正文问题? -- 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步? +- 【流程进度】里已完成的非反复技能,是否误当成 fresh 又排了一次?要改旧稿应 mode=revise +- askUser 是否只问了排哪步 / 填哪个参数 / 新建还是回头修改,而不是体验偏好等正文问题? +- 需要反复补同类内容时,是否用了新 id + 新 params + fresh 追加(彻底新建),而不是改写旧步? +- 用户要改已完成节点时,是否用了 mode=revise + revises,而不是再排一条 fresh? - 收成前是否把 `status` 设为 `closed`? diff --git a/skills/dialogue/world-simulator/workers/design-step/SKILL.md b/skills/dialogue/world-simulator/workers/design-step/SKILL.md index efb266b..981d369 100644 --- a/skills/dialogue/world-simulator/workers/design-step/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-step/SKILL.md @@ -29,24 +29,37 @@ contextSegments: label: "## 【本步】当前工序 id(对应流程 steps[].id)" - id: user-demand tier: dynamic - tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"] + tags: ["用户.需求", "book.brief", "用户.worker答复"] label: "## 用户表述" --- # 创作 · 执行步骤 -你只做 **【本步】** 标明的那一个工序(流程里的一步 id → 能力 name)。 +你执行工作流计划里 **【本步】** 那一个工序(步骤 id → 能力 name):按注入的【本步方法】写出该步产物(程序指定的 output tag),交用户验收。其它步骤产物不改;流程只读。 -程序会在提示词中追加该工序的方法正文,并注入依赖步骤的已验收产物。 +程序会追加该工序的方法正文,并注入依赖步骤的已验收产物。 -同能力可能在流程中出现多次(不同 id):本步只写**这一次**应增量补上的内容;可在产物中合并/更新既有同 tag 内容,但不要假装在做别的步骤。 +同能力可多次出现(不同 id): -## 纪律 +- **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。 +- **回头修改**(mode=revise):以【既有产物 · 继承修改】为底稿,改用户点名的部分,保留其余;不从零另起,不再走开场询问。 +- **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。 -1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物 -2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名 -3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白 -4. 信息不足:context-fragment 产物把题写在「追问」(1~2 点,建议选项 + 示例),顶层 askUser 为 null。仅完全无法产出时才用顶层 askUser -5. `summary`:`{本步能力名} · …` -6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow -7. **本步参数**由编排期写入 steps[].params,程序会注入【本步参数】。按参数执行;禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参 +## 执行 + +1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。 +2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。 +3. 按方法正文填产物。〔先验产物〕读【规划产物】当起点(可空、可在步内修订);其它节点编排参数见【本步参数】,直接采用。 +4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …` + +## 缺料时 + +「不足」只指上面第 1 步认定的事项还不能验收。 + +- 缺其中一件:在产物「追问」写 1~2 点(建议选项 + 示例),顶层 askUser 为 null。半残 JSON 仍写入 outputs 交验收。仅完全没有可展示正文时才用顶层 askUser。 +- 这些事项够验收:`追问.题目` 为 `[]`。方法未点名为必须钉死的键,写「未定」或省略,不发问。 +- 用户谈到 `boundary` 里交给其它技能的话题:只抽取能改本步必钉事项的一句写入正文,其余写入产物「开放问题」(无该键则在说明里记一句),本步不就此追问。 +- 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。 +- 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。 + +需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。 diff --git a/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md index f065d3f..7688e81 100644 --- a/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md +++ b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md @@ -3,8 +3,8 @@ id: opening-generator skill: world-simulator name: 开局 · 开场白 description: >- - 【创作末尾】优先落库「设计.开场白与开场变量」;若无则结合已定规格写出开场白, - 并钉与开场同真相的初值。主产物是开场白,不是填表。须用户验收。 + 【兜底】DAG 未排「开场白与开场变量」时,在创作末尾落库开场白与初值。 + 若该节点已选定,程序会收口,不必再调本 worker。 version: 3 stage: design-end inputTags: @@ -29,13 +29,18 @@ contextSegments: label: "## 已定世界与故事规格" - id: user tier: dynamic - tags: ["用户.需求", "用户.最新输入", "用户.worker答复", "用户.修订说明", "运行.初始变量", "输出.开场白"] - label: "## 用户与开局草稿" + tags: ["用户.需求", "用户.最新输入", "用户.worker答复", "用户.修订说明"] + label: "## 用户表述" + - id: opening-draft + tier: static + tags: ["运行.初始变量", "输出.开场白"] + label: "## 【已有开场】只保留最新稿;若有则在其上改,不要另起" --- # 开局 · 开场白 -创作末尾:规格已定。优先读取 **`设计.开场白与开场变量`**: +创作末尾兜底(DAG **没有**「开场白与开场变量」时才需要本 worker)。 +规格已定。优先读取 **`设计.开场白与开场变量`**: - 若已有且可用:把其中「开场白全文」写入 `输出.开场白`,把「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。 - 若无:再站在 Worker 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。 diff --git a/src/book/snapshot-filters.ts b/src/book/snapshot-filters.ts index ce25741..4aa5d8a 100644 --- a/src/book/snapshot-filters.ts +++ b/src/book/snapshot-filters.ts @@ -1,5 +1,6 @@ import type { BlackboardItem } from "../types/blackboard.js"; import type { RuntimeSession } from "../types/runtime.js"; +import { SLOT_PLAY_TURN_QUEUE, SLOT_PLAY_LAYER_ACTIVE } from "../skills/play-turn.js"; /** * 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。 @@ -12,9 +13,14 @@ export function isRunPhaseBlackboardTag(tag: string): boolean { if (/^book\./.test(tag)) return false; if (/^情境\./.test(tag)) return false; if (/^博弈\./.test(tag)) return false; + // 锁定开局:跟规格一起进 instance + if (tag === "输出.开场白" || tag === "运行.初始变量" || tag === "变量.当前") { + return false; + } - // run 层:剥离 + // run 层:剥离(含聊天历史,不写进角色卡) return ( + tag === "对话.历史" || /^运行\./.test(tag) || /^世界\.(当前|裁决)/.test(tag) || /^场景\.公开/.test(tag) || @@ -35,6 +41,11 @@ export function prepareRuntimeSessionForInstance(session: RuntimeSession): Runti copy.pendingDecision = undefined; copy.currentWorkerId = undefined; copy.waitingReason = undefined; + copy.slots = { + ...copy.slots, + [SLOT_PLAY_TURN_QUEUE]: undefined, + [SLOT_PLAY_LAYER_ACTIVE]: undefined, + }; copy.artifacts = copy.artifacts.filter((a) => a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)), ); diff --git a/src/llm/client.ts b/src/llm/client.ts index 9019d09..6a4fcee 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -1,6 +1,10 @@ import type { LlmConfig } from "../config/env.js"; import type { GenerationParameters } from "../types/preset.js"; import { consumeOpenAiToolStream } from "./stream-complete.js"; +import { + currentAbortSignal, + throwIfAborted, +} from "./run-abort.js"; export type ToolCallPayload = { id: string; @@ -58,6 +62,8 @@ export type CompleteOptions = { generation?: GenerationParameters; /** 统计用途,如 main_agent / worker:write-rules */ caller?: string; + /** 取消进行中的 LLM 请求(停止并重试) */ + signal?: AbortSignal; }; export type CompleteWithToolsOptions = CompleteOptions & { @@ -237,6 +243,24 @@ function extractMessageParts(message: Record | undefined): { return { content: "", toolCalls }; } +async function llmFetch( + url: string, + apiKey: string, + body: unknown, + options?: CompleteOptions, +): Promise { + throwIfAborted(options?.signal); + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: options?.signal ?? currentAbortSignal(), + }); +} + export class OpenAiCompatibleProvider implements LlmProvider { constructor(private readonly config: LlmConfig) {} @@ -245,14 +269,12 @@ export class OpenAiCompatibleProvider implements LlmProvider { options?: CompleteOptions, ): Promise { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify(buildRequestBody(this.config, messages, options)), - }); + const response = await llmFetch( + url, + this.config.apiKey, + buildRequestBody(this.config, messages, options), + options, + ); if (!response.ok) { const body = await response.text(); @@ -282,16 +304,12 @@ export class OpenAiCompatibleProvider implements LlmProvider { callbacks: StreamCallbacks = {}, ): Promise { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify( - buildRequestBody(this.config, messages, { ...options, stream: true }), - ), - }); + const response = await llmFetch( + url, + this.config.apiKey, + buildRequestBody(this.config, messages, { ...options, stream: true }), + options, + ); if (!response.ok) { const body = await response.text(); @@ -319,19 +337,15 @@ export class OpenAiCompatibleProvider implements LlmProvider { options: CompleteWithToolsOptions, ): Promise { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify( - buildRequestBody(this.config, messages, { - ...options, - tools: options.tools, - }), - ), - }); + const response = await llmFetch( + url, + this.config.apiKey, + buildRequestBody(this.config, messages, { + ...options, + tools: options.tools, + }), + options, + ); if (!response.ok) { const body = await response.text(); @@ -362,20 +376,16 @@ export class OpenAiCompatibleProvider implements LlmProvider { callbacks: StreamCallbacks, ): Promise { const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify( - buildRequestBody(this.config, messages, { - ...options, - tools: options.tools, - stream: true, - }), - ), - }); + const response = await llmFetch( + url, + this.config.apiKey, + buildRequestBody(this.config, messages, { + ...options, + tools: options.tools, + stream: true, + }), + options, + ); if (!response.ok) { const body = await response.text(); @@ -425,6 +435,7 @@ export class MockLlmProvider implements LlmProvider { _messages: ChatMessage[], options?: CompleteOptions, ): Promise { + throwIfAborted(options?.signal); const step = this.nextStep(); const response = typeof step === "string" @@ -448,6 +459,7 @@ export class MockLlmProvider implements LlmProvider { options?: CompleteOptions, callbacks: StreamCallbacks = {}, ): Promise { + throwIfAborted(options?.signal); const step = this.nextStep(); const response = typeof step === "string" @@ -455,10 +467,12 @@ export class MockLlmProvider implements LlmProvider { : (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" })); const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。"; for (const ch of reasoning) { + throwIfAborted(options?.signal); callbacks.onReasoningDelta?.(ch); await new Promise((r) => setTimeout(r, 0)); } for (const ch of response) { + throwIfAborted(options?.signal); callbacks.onContentDelta?.(ch); await new Promise((r) => setTimeout(r, 0)); } @@ -480,6 +494,7 @@ export class MockLlmProvider implements LlmProvider { _messages: ChatMessage[], _options: CompleteWithToolsOptions, ): Promise { + throwIfAborted(_options?.signal); const step = this.nextStep(); if (typeof step === "string") { return { @@ -507,9 +522,11 @@ export class MockLlmProvider implements LlmProvider { _options: CompleteWithToolsOptions, callbacks: StreamCallbacks, ): Promise { + throwIfAborted(_options?.signal); const reasoning = "用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。"; for (const ch of reasoning) { + throwIfAborted(_options?.signal); callbacks.onReasoningDelta?.(ch); await new Promise((r) => setTimeout(r, 0)); } diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index 38bc3fe..aa89b38 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -1,4 +1,5 @@ -import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js"; +import { loadAppSettings } from "../config/settings.js"; +import { resolveActivePreset } from "../preset/store.js"; import type { PresetPackage } from "../types/preset.js"; import type { ChatMessage, @@ -9,9 +10,35 @@ import type { LlmProvider, StreamCallbacks, } from "./client.js"; +import { + TokenTrackingProvider, + type LlmTrackingContext, +} from "./token-tracker.js"; + +export type LlmTrackingRef = { current: LlmTrackingContext }; + +function readActivePreset(): PresetPackage | null { + return resolveActivePreset(loadAppSettings().activePresetId); +} /** - * 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。 + * 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。 + * 当前没有选用预设时仍包一层,请求原样转发。 + */ +export function wrapLlmForSession( + inner: LlmProvider, + trackingRef?: LlmTrackingRef, + getPreset: () => PresetPackage | null = readActivePreset, +): LlmProvider { + const tracked = trackingRef + ? new TokenTrackingProvider(inner, () => trackingRef.current) + : inner; + return new PresetLlmProvider(tracked, getPreset); +} + +/** + * 在所有 LLM 请求上合并当前 preset 的生成参数。 + * prompt / marker 不在此前置,由 play worker 按扩展 prompt_order 填洞。 */ export class PresetLlmProvider implements LlmProvider { constructor( @@ -19,41 +46,57 @@ export class PresetLlmProvider implements LlmProvider { private readonly getPreset: () => PresetPackage | null, ) {} + private prepare( + messages: ChatMessage[], + options?: CompleteOptions, + ): { messages: ChatMessage[]; options: CompleteOptions | undefined } { + const preset = this.getPreset(); + if (!preset) return { messages, options }; + // 生成参数全局合并;prompt/marker 只在 play worker 按 prompt_order 填洞,这里不再前置。 + return { + messages, + options: { + ...options, + generation: options?.generation ?? preset.generation, + }, + }; + } + async complete( messages: ChatMessage[], options?: CompleteOptions, ): Promise { - const preset = this.getPreset(); - if (!preset) { - return this.inner.complete(messages, options); + const prepared = this.prepare(messages, options); + return this.inner.complete(prepared.messages, prepared.options); + } + + async completeStream( + messages: ChatMessage[], + options?: CompleteOptions, + callbacks: StreamCallbacks = {}, + ): Promise { + if (!this.inner.completeStream) { + const result = await this.complete(messages, options); + if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning); + if (result.content) callbacks.onContentDelta?.(result.content); + return result; } - - const presetMessages = assemblePresetMessages(preset); - const merged = mergeMessages(presetMessages, messages); - const generation = options?.generation ?? preset.generation; - - return this.inner.complete(merged, { - ...options, - generation, - }); + const prepared = this.prepare(messages, options); + return this.inner.completeStream( + prepared.messages, + prepared.options, + callbacks, + ); } async completeWithTools( messages: ChatMessage[], options: CompleteWithToolsOptions, ): Promise { - const preset = this.getPreset(); - if (!preset) { - return this.inner.completeWithTools(messages, options); - } - - const presetMessages = assemblePresetMessages(preset); - const merged = mergeMessages(presetMessages, messages); - const generation = options.generation ?? preset.generation; - - return this.inner.completeWithTools(merged, { + const prepared = this.prepare(messages, options); + return this.inner.completeWithTools(prepared.messages, { ...options, - generation, + ...prepared.options, }); } @@ -62,17 +105,14 @@ export class PresetLlmProvider implements LlmProvider { options: CompleteWithToolsOptions, callbacks: StreamCallbacks, ): Promise { - const preset = this.getPreset(); if (!this.inner.completeWithToolsStream) { return this.completeWithTools(messages, options); } - const presetMessages = preset ? assemblePresetMessages(preset) : []; - const merged = preset ? mergeMessages(presetMessages, messages) : messages; - const generation = options.generation ?? preset?.generation; - - return this.inner.completeWithToolsStream(merged, { - ...options, - generation, - }, callbacks); + const prepared = this.prepare(messages, options); + return this.inner.completeWithToolsStream( + prepared.messages, + { ...options, ...prepared.options }, + callbacks, + ); } } diff --git a/src/llm/run-abort.ts b/src/llm/run-abort.ts new file mode 100644 index 0000000..af91725 --- /dev/null +++ b/src/llm/run-abort.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +const storage = new AsyncLocalStorage(); + +export function runWithAbortSignal( + signal: AbortSignal, + fn: () => Promise, +): Promise { + return storage.run(signal, fn); +} + +export function currentAbortSignal(): AbortSignal | undefined { + return storage.getStore(); +} + +export function isAbortError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const name = "name" in err ? String(err.name) : ""; + if (name === "AbortError") return true; + const message = "message" in err ? String(err.message) : ""; + return /aborted|AbortError/i.test(message); +} + +export function throwIfAborted(signal?: AbortSignal): void { + const active = signal ?? currentAbortSignal(); + if (!active?.aborted) return; + const err = new Error("This operation was aborted"); + err.name = "AbortError"; + throw err; +} diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts index f5785a3..1831c40 100644 --- a/src/llm/token-tracker.ts +++ b/src/llm/token-tracker.ts @@ -32,14 +32,21 @@ export type LlmTrackingContext = { function capturePendingTrace( ctx: LlmTrackingContext, - messages: Array<{ role: string; content: string }>, + messages: Array<{ + role: string; + content?: string | null; + tool_calls?: unknown; + tool_call_id?: string; + }>, caller: string | undefined, model?: string, + generation?: CompleteOptions["generation"], ): void { ctx.pendingContextTrace = buildContextTrace({ caller: caller ?? "unknown", messages, model, + generation, }); } @@ -130,7 +137,13 @@ export class TokenTrackingProvider implements LlmProvider { async () => { const result = await this.inner.complete(messages, options); const ctx = this.getContext(); - capturePendingTrace(ctx, messages, options?.caller, result.model); + capturePendingTrace( + ctx, + messages, + options?.caller, + result.model, + options?.generation, + ); recordUsage(ctx, options?.caller, result.model, result.usage); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); @@ -168,7 +181,13 @@ export class TokenTrackingProvider implements LlmProvider { onContentDelta: callbacks.onContentDelta, }); const ctx = this.getContext(); - capturePendingTrace(ctx, messages, options?.caller, result.model); + capturePendingTrace( + ctx, + messages, + options?.caller, + result.model, + options?.generation, + ); recordUsage(ctx, options?.caller, result.model, result.usage); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); @@ -189,7 +208,13 @@ export class TokenTrackingProvider implements LlmProvider { async () => { const result = await this.inner.completeWithTools(messages, options); const ctx = this.getContext(); - capturePendingTrace(ctx, messages, options.caller, result.model); + capturePendingTrace( + ctx, + messages, + options.caller, + result.model, + options.generation, + ); recordUsage(ctx, options.caller, result.model, result.usage); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); @@ -224,7 +249,13 @@ export class TokenTrackingProvider implements LlmProvider { onContentDelta: callbacks.onContentDelta, }); const ctx = this.getContext(); - capturePendingTrace(ctx, messages, options.caller, result.model); + capturePendingTrace( + ctx, + messages, + options.caller, + result.model, + options.generation, + ); recordUsage(ctx, options.caller, result.model, result.usage); if (result.reasoning?.trim()) { ctx.pendingReasoning = result.reasoning.trim(); diff --git a/src/log.ts b/src/log.ts index 92f5246..a53a051 100644 --- a/src/log.ts +++ b/src/log.ts @@ -77,6 +77,9 @@ const EFFECT_LABEL: Record = { run_programmatic_review: "程序验收", emit_message: "发消息", propose_next_creation_step: "提案下一步", + seal_creation_opening: "收口开场白", + run_play_turn: "开游玩回合", + continue_play_turn: "续游玩管线", }; const TOOL_LABEL: Record = { diff --git a/src/main-agent/main-agent.ts b/src/main-agent/main-agent.ts index 47ff122..5af7d41 100644 --- a/src/main-agent/main-agent.ts +++ b/src/main-agent/main-agent.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { LlmProvider } from "../llm/client.js"; +import { createLocalLlmDriver, type AgentDriver } from "../runtime/driver.js"; import { normalizeQuestions } from "../skills/question-protocol.js"; import type { BlackboardTagIndex } from "../types/blackboard.js"; import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js"; @@ -45,7 +46,14 @@ ${workerLines} } export class MainAgent { - constructor(private readonly llm: LlmProvider) {} + private readonly driver: AgentDriver; + + constructor( + private readonly llm: LlmProvider, + driver?: AgentDriver, + ) { + this.driver = driver ?? createLocalLlmDriver(llm); + } /** @deprecated 单次 JSON 决策;请用 runToolLoop */ async decide(context: MainAgentContext): Promise { @@ -70,6 +78,7 @@ export class MainAgent { this.llm, context, options.handlers, + this.driver, ); return decision; } diff --git a/src/main-agent/tool-loop.ts b/src/main-agent/tool-loop.ts index eb23564..700d3fe 100644 --- a/src/main-agent/tool-loop.ts +++ b/src/main-agent/tool-loop.ts @@ -1,12 +1,15 @@ -import type { ChatMessage, LlmProvider, ParsedToolCall, StreamCallbacks } from "../llm/client.js"; -import { supportsToolStream } from "../llm/stream-complete.js"; +import type { LlmProvider, ParsedToolCall } from "../llm/client.js"; import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js"; +import { + createLocalLlmDriver, + type AgentDriver, + type DriverToolCall, +} from "../runtime/driver.js"; import type { MainAgentDecision } from "../types/runtime.js"; import { isMainAgentTerminalTool } from "../types/tools.js"; import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js"; import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js"; import type { MainAgentContext } from "./main-agent.js"; -import { debugLog } from "../log.js"; export type ToolLoopHandlers = { readBlackboard: (tags: string[]) => Record; @@ -31,8 +34,6 @@ export type ToolLoopResult = { toolTrace: string[]; }; -const MAX_TOOL_LOOP_ITERATIONS = 12; - function buildToolLoopSystemPrompt( workers: Array<{ id: string; description: string }>, ): string { @@ -54,35 +55,15 @@ ${workerLines} 选定节点后立即调用 run_worker。`; } -async function completeToolsPreferStream( - llm: LlmProvider, - messages: ChatMessage[], - callbacks: StreamCallbacks, -): Promise>> { - if (supportsToolStream(llm)) { - return llm.completeWithToolsStream!(messages, { - tools: MAIN_AGENT_TOOL_DEFINITIONS, - caller: "main_agent", - }, callbacks); - } - const result = await llm.completeWithTools(messages, { - tools: MAIN_AGENT_TOOL_DEFINITIONS, - caller: "main_agent", - }); - if (result.reasoning) { - callbacks.onReasoningDelta?.(result.reasoning); - } - if (result.content) { - callbacks.onContentDelta?.(result.content); - } - return result; -} - function executeLoopTool( - call: ParsedToolCall, + call: DriverToolCall | ParsedToolCall, handlers: ToolLoopHandlers, ): string { - const name = validateLoopToolCall(call); + const name = validateLoopToolCall({ + id: call.id, + name: call.name, + arguments: call.arguments, + }); const args = JSON.parse(call.arguments || "{}") as Record; switch (name) { @@ -104,102 +85,66 @@ function executeLoopTool( } } -function assistantMessageFromToolCalls( - content: string | null, - toolCalls: ParsedToolCall[], -): ChatMessage { - return { - role: "assistant", - content, - tool_calls: toolCalls.map((tc) => ({ - id: tc.id, - type: "function" as const, - function: { name: tc.name, arguments: tc.arguments }, - })), - }; -} - -function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void { - const text = reasoning?.trim() ?? ""; - if (text) handlers.onThinkingDone?.(text); -} - /** * 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等, * 直到调用终止 tool 并返回 MainAgentDecision。 + * + * 循环本身交给 {@link AgentDriver};本函数只解释总管 tool 语义。 */ export async function runMainAgentToolLoop( llm: LlmProvider, context: MainAgentContext, handlers: ToolLoopHandlers, + driver: AgentDriver = createLocalLlmDriver(llm), ): Promise { - const messages: ChatMessage[] = [ - { - role: "system", - content: buildToolLoopSystemPrompt(context.availableWorkers), - }, - { role: "user", content: buildMainAgentUserPrompt(context) }, - ]; - const toolTrace: string[] = []; - const streamCallbacks: StreamCallbacks = { - onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta), - }; - for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) { - debugLog("llm", `总管循环 第${iteration}轮`); - const result = await completeToolsPreferStream(llm, messages, streamCallbacks); - emitThinkingDone(handlers, result.reasoning); + const run = await driver.run({ + system: buildToolLoopSystemPrompt(context.availableWorkers), + messages: [{ role: "user", content: buildMainAgentUserPrompt(context) }], + tools: MAIN_AGENT_TOOL_DEFINITIONS, + caller: "main_agent", + label: "总管循环", + onThinkingDelta: (delta) => handlers.onThinkingDelta?.(delta), + onThinkingDone: (text) => handlers.onThinkingDone?.(text), + handleStep: (calls) => { + const terminalCalls = calls.filter((tc) => isMainAgentTerminalTool(tc.name)); + const loopCalls = calls.filter((tc) => !isMainAgentTerminalTool(tc.name)); - if (result.toolCalls.length === 0) { - if (result.content?.trim()) { - const decision = parseMainAgentDecision(result.content); - return { decision, iterations: iteration, toolTrace }; + if (terminalCalls.length > 1) { + throw new Error( + `Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`, + ); } - throw new Error("Main Agent returned no tool calls and no content"); - } - const terminalCalls = result.toolCalls.filter((tc) => - isMainAgentTerminalTool(tc.name), - ); - const loopCalls = result.toolCalls.filter( - (tc) => !isMainAgentTerminalTool(tc.name), - ); + if (terminalCalls.length === 1) { + const terminal = terminalCalls[0]; + handlers.onToolCall?.(terminal.name, terminal.arguments); + toolTrace.push(`${terminal.name} (terminal)`); + return { kind: "conclude" as const, call: terminal }; + } - if (terminalCalls.length > 1) { - throw new Error( - `Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`, - ); - } - - messages.push( - assistantMessageFromToolCalls(result.content, result.toolCalls), - ); - - if (terminalCalls.length === 1) { - const terminal = terminalCalls[0]; - handlers.onToolCall?.(terminal.name, terminal.arguments); - toolTrace.push(`${terminal.name} (terminal)`); - return { - decision: toolCallToDecision(terminal), - iterations: iteration, - toolTrace, - }; - } - - for (const call of loopCalls) { - const output = executeLoopTool(call, handlers); - handlers.onToolCall?.(call.name, output.slice(0, 200)); - toolTrace.push(call.name); - messages.push({ - role: "tool", - content: output, - tool_call_id: call.id, + const results = loopCalls.map((call) => { + const output = executeLoopTool(call, handlers); + handlers.onToolCall?.(call.name, output.slice(0, 200)); + toolTrace.push(call.name); + return { callId: call.id, content: output }; }); - } + return { kind: "continue" as const, results }; + }, + }); + + if (run.stop.kind === "text") { + return { + decision: parseMainAgentDecision(run.stop.content), + iterations: run.iterations, + toolTrace, + }; } - throw new Error( - `Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`, - ); + return { + decision: toolCallToDecision(run.stop.call), + iterations: run.iterations, + toolTrace, + }; } diff --git a/src/preset/assembler.ts b/src/preset/assembler.ts index 9c51f6e..0b75d9f 100644 --- a/src/preset/assembler.ts +++ b/src/preset/assembler.ts @@ -1,12 +1,14 @@ import type { ChatMessage } from "../llm/client.js"; import type { PresetPackage } from "../types/preset.js"; +import { isStUnfilledMarker } from "./markers.js"; export type MarkerResolver = (identifier: string) => string | null; const defaultMarkerResolver: MarkerResolver = () => null; /** - * 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。 + * 按 prompt_order 装配 preset 消息。 + * 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。 */ export function assemblePresetMessages( preset: PresetPackage, @@ -25,8 +27,10 @@ export function assemblePresetMessages( if (!entry || !entry.enabled) continue; let content = entry.content.trim(); - if (!content && entry.marker) { - const resolved = resolveMarker(entry.sourceIdentifier); + const markerId = entry.sourceIdentifier || entry.id; + const skipFill = isStUnfilledMarker(markerId); + if (!content && entry.marker && !skipFill) { + const resolved = resolveMarker(markerId); if (resolved?.trim()) content = resolved.trim(); } diff --git a/src/preset/importer.ts b/src/preset/importer.ts index a43a1de..2b156f9 100644 --- a/src/preset/importer.ts +++ b/src/preset/importer.ts @@ -7,6 +7,7 @@ import type { PresetPromptOrderItem, UnsupportedPresetSection, } from "../types/preset.js"; +import { applyExtendedMarkers } from "./markers.js"; type StPrompt = { identifier?: string; @@ -186,17 +187,6 @@ export function importSillyTavernPreset( }); }); - const referenced = new Set(promptOrder.map((o) => o.promptId)); - const unreferencedCount = prompts.filter( - (p) => !referenced.has(p.id), - ).length; - - const enabledCount = promptOrder.filter((o) => { - if (!o.enabled) return false; - const entry = prompts.find((p) => p.id === o.promptId); - return entry?.enabled !== false; - }).length; - const generation = readGeneration(raw); const generationFields = Object.entries(generation) .filter(([, v]) => v !== undefined) @@ -211,7 +201,7 @@ export function importSillyTavernPreset( ? (rawInput as { name: string }).name : "导入的预设"); - const preset: PresetPackage = { + const preset: PresetPackage = applyExtendedMarkers({ id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`, name: presetName, source: "sillytavern", @@ -221,13 +211,23 @@ export function importSillyTavernPreset( unsupported: collectUnsupported(raw), importedAt: new Date().toISOString(), raw: rawInput, - }; + }); + + const framedReferenced = new Set(preset.promptOrder.map((o) => o.promptId)); + const framedUnreferenced = preset.prompts.filter( + (p) => !framedReferenced.has(p.id), + ).length; + const framedEnabled = preset.promptOrder.filter((o) => { + if (!o.enabled) return false; + const entry = preset.prompts.find((p) => p.id === o.promptId); + return entry?.enabled !== false; + }).length; return { preset, - promptCount: prompts.length, - enabledCount, - unreferencedCount, + promptCount: preset.prompts.length, + enabledCount: framedEnabled, + unreferencedCount: framedUnreferenced, missingIdentifiers, generationFields, warnings, diff --git a/src/preset/markers.ts b/src/preset/markers.ts new file mode 100644 index 0000000..b06d4be --- /dev/null +++ b/src/preset/markers.ts @@ -0,0 +1,132 @@ +import type { + PresetPackage, + PresetPromptEntry, + PresetPromptOrderItem, + PresetPromptRole, +} from "../types/preset.js"; + +/** 酒馆角色卡 / WI marker:导入后保留位置,运行时不填我们的正文。 */ +export const ST_UNFILLED_MARKERS = new Set([ + "worldInfoBefore", + "personaDescription", + "charDescription", + "charPersonality", + "scenario", + "worldInfoAfter", + "dialogueExamples", +]); + +export const CHAT_HISTORY_MARKER = "chatHistory"; +export const WORLD_BOOK_BEFORE = "worldBookBefore"; +export const WORLD_BOOK_AFTER = "worldBookAfter"; +export const CURRENT_TURN = "currentTurn"; +export const POST_TURN = "postTurn"; + +/** 导入时插入的自有洞(不含共用的 chatHistory)。 */ +export const APP_INSERTED_MARKERS = [ + WORLD_BOOK_BEFORE, + WORLD_BOOK_AFTER, + CURRENT_TURN, + POST_TURN, +] as const; + +const APP_INSERTED_SET = new Set(APP_INSERTED_MARKERS); + +type MarkerDef = { + id: string; + name: string; + role: PresetPromptRole; +}; + +const MARKER_DEFS: MarkerDef[] = [ + { id: WORLD_BOOK_BEFORE, name: "世界书(历史前)", role: "system" }, + { id: CHAT_HISTORY_MARKER, name: "对话历史", role: "system" }, + { id: WORLD_BOOK_AFTER, name: "世界书(历史后)", role: "system" }, + { id: CURRENT_TURN, name: "本轮对话", role: "user" }, + { id: POST_TURN, name: "本轮之后", role: "system" }, +]; + +function upsertMarkerEntry( + prompts: PresetPromptEntry[], + def: MarkerDef, +): PresetPromptEntry[] { + if (prompts.some((p) => p.id === def.id)) return prompts; + return [ + ...prompts, + { + id: def.id, + name: def.name, + enabled: true, + role: def.role, + content: "", + marker: true, + sourceIdentifier: def.id, + }, + ]; +} + +function reindex( + order: Omit[], +): PresetPromptOrderItem[] { + return order.map((item, orderIndex) => ({ ...item, orderIndex })); +} + +/** + * 以 chatHistory 为锚点插入四个自有 marker。 + * 可重复调用:先摘掉已插入的自有洞,再按规则放回。 + */ +export function applyExtendedMarkers(preset: PresetPackage): PresetPackage { + let prompts = [...preset.prompts]; + for (const def of MARKER_DEFS) { + prompts = upsertMarkerEntry(prompts, def); + } + + const rawOrder = [...preset.promptOrder] + .sort((a, b) => a.orderIndex - b.orderIndex) + .filter((item) => !APP_INSERTED_SET.has(item.promptId)); + + const histIndex = rawOrder.findIndex( + (item) => item.promptId === CHAT_HISTORY_MARKER, + ); + + const inserted = (id: string): PresetPromptOrderItem => ({ + promptId: id, + enabled: true, + orderIndex: 0, + }); + + let next: PresetPromptOrderItem[]; + if (histIndex < 0) { + next = reindex([ + ...rawOrder, + inserted(WORLD_BOOK_BEFORE), + inserted(CHAT_HISTORY_MARKER), + inserted(WORLD_BOOK_AFTER), + inserted(CURRENT_TURN), + inserted(POST_TURN), + ]); + } else { + const before = rawOrder.slice(0, histIndex); + const history = rawOrder[histIndex]!; + const after = rawOrder.slice(histIndex + 1); + next = reindex([ + ...before, + inserted(WORLD_BOOK_BEFORE), + history, + inserted(WORLD_BOOK_AFTER), + inserted(CURRENT_TURN), + ...after, + inserted(POST_TURN), + ]); + } + + return { + ...preset, + prompts, + promptOrder: next, + }; +} + +export function isStUnfilledMarker(identifier: string): boolean { + return ST_UNFILLED_MARKERS.has(identifier); +} diff --git a/src/preset/play-frame.ts b/src/preset/play-frame.ts new file mode 100644 index 0000000..a65d6ab --- /dev/null +++ b/src/preset/play-frame.ts @@ -0,0 +1,24 @@ +import type { ChatMessage } from "../llm/client.js"; +import type { PresetPackage } from "../types/preset.js"; +import { assemblePresetMessages } from "./assembler.js"; +import { applyExtendedMarkers } from "./markers.js"; +import { + resolveWorldInfoMarker, + type WorldInfoPack, +} from "./world-info-pack.js"; + +/** + * Play worker:system 契约 + 按扩展 prompt_order 填洞。 + * 不要再把同一份 context-order 正文叠成第二包 user。 + */ +export function assemblePlayWorkerMessages(params: { + systemPrompt: string; + preset: PresetPackage; + pack: WorldInfoPack; +}): ChatMessage[] { + const framed = applyExtendedMarkers(params.preset); + const filled = assemblePresetMessages(framed, (id) => + resolveWorldInfoMarker(params.pack, id), + ); + return [{ role: "system", content: params.systemPrompt }, ...filled]; +} diff --git a/src/preset/probe.ts b/src/preset/probe.ts new file mode 100644 index 0000000..4a2d450 --- /dev/null +++ b/src/preset/probe.ts @@ -0,0 +1,130 @@ +import type { ChatMessage } from "../llm/client.js"; +import type { PresetPackage } from "../types/preset.js"; +import { assemblePlayWorkerMessages } from "./play-frame.js"; +import type { WorldInfoPack } from "./world-info-pack.js"; + +/** 试跑窗口用的短契约,不是 play worker SKILL。 */ +export const PROBE_SYSTEM_PROMPT = + "试跑本条预设:根据已拼好的上下文写一轮短回复。产物是对白或叙述,对应本轮用户输入。"; + +export const DEFAULT_PROBE_CONTEXT = { + loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。", + history: "店员:今晚只剩阁楼那间。\n你:好。", + loreAfter: "时间:深夜。地点:旅馆大厅。", + postTurn: "", +}; + +export type PresetProbeInput = { + message: string; + loreBefore?: string; + history?: string; + loreAfter?: string; + postTurn?: string; +}; + +export type PresetProbeMessage = { + role: string; + content: string; +}; + +export type PresetProbeResult = { + messages: PresetProbeMessage[]; + reply: string | null; + reasoning: string | null; + completed: boolean; + skipReason?: string; + error?: string; +}; + +function optionalEntry( + id: string, + name: string, + content: string, +): WorldInfoPack["worldBookBefore"] { + const text = content.trim(); + return text ? [{ id, name, content: text }] : []; +} + +export function worldInfoPackFromProbeInput( + input: PresetProbeInput, +): WorldInfoPack { + const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore; + const history = input.history ?? DEFAULT_PROBE_CONTEXT.history; + const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter; + const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn; + + return { + worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore), + history: history.trim(), + worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter), + turn: input.message.trim(), + postTurn: optionalEntry("probe-post", "尾注", postTurn), + }; +} + +export function assemblePresetProbe( + preset: PresetPackage, + input: PresetProbeInput, +): ChatMessage[] { + return assemblePlayWorkerMessages({ + systemPrompt: PROBE_SYSTEM_PROMPT, + preset, + pack: worldInfoPackFromProbeInput(input), + }); +} + +export function serializeProbeMessages( + messages: ChatMessage[], +): PresetProbeMessage[] { + return messages.map((m) => ({ + role: m.role, + content: typeof m.content === "string" ? m.content : String(m.content ?? ""), + })); +} + +export async function runPresetProbe(params: { + preset: PresetPackage; + input: PresetProbeInput; + complete?: ( + messages: ChatMessage[], + ) => Promise<{ content: string; reasoning?: string }>; +}): Promise { + const message = params.input.message.trim(); + if (!message) { + throw new Error("缺少本轮输入"); + } + + const messages = assemblePresetProbe(params.preset, { + ...params.input, + message, + }); + const serialized = serializeProbeMessages(messages); + + if (!params.complete) { + return { + messages: serialized, + reply: null, + reasoning: null, + completed: false, + skipReason: "未请求生成", + }; + } + + try { + const result = await params.complete(messages); + return { + messages: serialized, + reply: result.content, + reasoning: result.reasoning ?? null, + completed: true, + }; + } catch (err) { + return { + messages: serialized, + reply: null, + reasoning: null, + completed: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/preset/world-info-pack.ts b/src/preset/world-info-pack.ts new file mode 100644 index 0000000..ef5f7c7 --- /dev/null +++ b/src/preset/world-info-pack.ts @@ -0,0 +1,157 @@ +import type { Blackboard } from "../blackboard/blackboard.js"; +import type { BlackboardInputMerge } from "../types/blackboard.js"; +import { + isDialogueHistoryRef, + DIALOGUE_HISTORY_TAG, +} from "../skills/dialogue-history.js"; +import { + renderContextSegmentBody, + type ContextSegmentDef, +} from "../skills/context-segments.js"; +import { + CHAT_HISTORY_MARKER, + CURRENT_TURN, + POST_TURN, + WORLD_BOOK_AFTER, + WORLD_BOOK_BEFORE, + isStUnfilledMarker, +} from "./markers.js"; + +export const CURRENT_TURN_TAG = "用户.最新输入"; + +const CURRENT_TURN_ALIASES = new Set([ + CURRENT_TURN_TAG, + "currentTurn", + "user.latest", + "用户最新输入", +]); + +export function isCurrentTurnRef(ref: string): boolean { + return CURRENT_TURN_ALIASES.has(ref.trim()); +} + +export type WorldInfoEntry = { + id: string; + name: string; + content: string; +}; + +export type WorldInfoPack = { + worldBookBefore: WorldInfoEntry[]; + history: string; + worldBookAfter: WorldInfoEntry[]; + turn: string; + postTurn: WorldInfoEntry[]; +}; + +function emptyPack(): WorldInfoPack { + return { + worldBookBefore: [], + history: "", + worldBookAfter: [], + turn: "", + postTurn: [], + }; +} + +function entryName(segment: ContextSegmentDef): string { + const raw = (segment.label ?? "").replace(/^#+\s*/, "").trim(); + return raw || segment.id; +} + +function toEntry(segment: ContextSegmentDef, content: string): WorldInfoEntry { + return { + id: segment.id, + name: entryName(segment), + content: content.trim(), + }; +} + +export function formatWorldInfoEntries(entries: WorldInfoEntry[]): string { + return entries + .filter((e) => e.content.trim()) + .map((e) => { + const title = e.name.trim(); + const body = e.content.trim(); + return title ? `### ${title}\n\n${body}` : body; + }) + .join("\n\n"); +} + +/** + * 按 contextSegments 相对「对话.历史」「用户.最新输入」切成四袋。 + */ +export function worldInfoPackFromSegments(params: { + segments: ContextSegmentDef[]; + inputs: Record; + blackboard: Blackboard; + inputMerge?: BlackboardInputMerge; +}): WorldInfoPack { + const inputMerge = params.inputMerge ?? "latest"; + const pack = emptyPack(); + let phase: "before" | "after" | "post" = "before"; + + for (const segment of params.segments) { + const tags = segment.tags ?? []; + const isHist = tags.some((t) => isDialogueHistoryRef(t)); + const isTurn = tags.some((t) => isCurrentTurnRef(t)); + const body = renderContextSegmentBody( + segment, + params.inputs, + params.blackboard, + inputMerge, + ).trim(); + + if (isHist) { + pack.history = body; + phase = "after"; + continue; + } + if (isTurn) { + pack.turn = body; + phase = "post"; + continue; + } + if (!body) continue; + const entry = toEntry(segment, body); + if (phase === "before") pack.worldBookBefore.push(entry); + else if (phase === "after") pack.worldBookAfter.push(entry); + else pack.postTurn.push(entry); + } + + if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) { + pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim(); + } + if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) { + pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim(); + } + + return pack; +} + +export function resolveWorldInfoMarker( + pack: WorldInfoPack, + identifier: string, +): string | null { + if (isStUnfilledMarker(identifier)) return null; + switch (identifier) { + case WORLD_BOOK_BEFORE: { + const text = formatWorldInfoEntries(pack.worldBookBefore); + return text || null; + } + case CHAT_HISTORY_MARKER: + return pack.history.trim() || null; + case WORLD_BOOK_AFTER: { + const text = formatWorldInfoEntries(pack.worldBookAfter); + return text || null; + } + case CURRENT_TURN: + return pack.turn.trim() || null; + case POST_TURN: { + const text = formatWorldInfoEntries(pack.postTurn); + return text || null; + } + default: + return null; + } +} diff --git a/src/runtime/driver.ts b/src/runtime/driver.ts new file mode 100644 index 0000000..87d5b1a --- /dev/null +++ b/src/runtime/driver.ts @@ -0,0 +1,161 @@ +/** + * 可替换的 agent 司机:问模型 → 有 tool-call 则交给调用方处理 → 再问,直到不再欠请求。 + * + * 相位机、黑板、规格不走这里。换 dsh / 其它 harness 时只换 {@link AgentDriver} 实现。 + * + * @module runtime/driver + */ + +import type { + ChatMessage, + CompleteWithToolsResult, + LlmProvider, + StreamCallbacks, + ToolDefinition, +} from "../llm/client.js"; +import { supportsToolStream } from "../llm/stream-complete.js"; +import { debugLog } from "../log.js"; + +export type DriverToolCall = { + id: string; + name: string; + arguments: string; +}; + +/** 一步里所有 tool-call 的处理结果:继续把结果写回,或本步结账停循环。 */ +export type DriverStepOutcome = + | { kind: "continue"; results: Array<{ callId: string; content: string }> } + | { kind: "conclude"; call: DriverToolCall }; + +export type DriverRunInput = { + system: string; + messages: ChatMessage[]; + tools: ToolDefinition[]; + caller?: string; + /** 日志用短名,如「总管循环」 */ + label?: string; + maxIterations?: number; + onThinkingDelta?: (delta: string) => void; + onThinkingDone?: (text: string) => void; + handleStep: ( + calls: DriverToolCall[], + ) => DriverStepOutcome | Promise; +}; + +export type DriverStop = + | { kind: "text"; content: string } + | { kind: "conclude"; call: DriverToolCall }; + +export type DriverRunResult = { + iterations: number; + stop: DriverStop; +}; + +/** 司机:直到模型不再欠一次请求。 */ +export type AgentDriver = { + run(input: DriverRunInput): Promise; +}; + +export const DEFAULT_DRIVER_MAX_ITERATIONS = 12; + +function assistantMessageFromToolCalls( + content: string | null, + toolCalls: DriverToolCall[], +): ChatMessage { + return { + role: "assistant", + content, + tool_calls: toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { name: tc.name, arguments: tc.arguments }, + })), + }; +} + +function emitThinkingDone( + input: DriverRunInput, + reasoning?: string, +): void { + const text = reasoning?.trim() ?? ""; + if (text) input.onThinkingDone?.(text); +} + +async function completeToolsPreferStream( + llm: LlmProvider, + messages: ChatMessage[], + tools: ToolDefinition[], + caller: string | undefined, + callbacks: StreamCallbacks, +): Promise { + const options = { tools, caller }; + if (supportsToolStream(llm)) { + return llm.completeWithToolsStream!(messages, options, callbacks); + } + const result = await llm.completeWithTools(messages, options); + if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning); + if (result.content) callbacks.onContentDelta?.(result.content); + return result; +} + +/** 现有 OpenAI 兼容 provider 上的司机,行为对齐旧 tool-loop。 */ +export function createLocalLlmDriver(llm: LlmProvider): AgentDriver { + return { + async run(input: DriverRunInput): Promise { + const maxIterations = input.maxIterations ?? DEFAULT_DRIVER_MAX_ITERATIONS; + const messages: ChatMessage[] = [ + { role: "system", content: input.system }, + ...input.messages, + ]; + const streamCallbacks: StreamCallbacks = { + onReasoningDelta: (delta) => input.onThinkingDelta?.(delta), + }; + const label = input.label ?? "driver"; + + for (let iteration = 1; iteration <= maxIterations; iteration++) { + debugLog("llm", `${label} 第${iteration}轮`); + const result = await completeToolsPreferStream( + llm, + messages, + input.tools, + input.caller, + streamCallbacks, + ); + emitThinkingDone(input, result.reasoning); + + if (result.toolCalls.length === 0) { + const content = result.content?.trim() ?? ""; + if (content) { + return { iterations: iteration, stop: { kind: "text", content } }; + } + throw new Error("Driver returned no tool calls and no content"); + } + + const calls: DriverToolCall[] = result.toolCalls.map((tc) => ({ + id: tc.id, + name: tc.name, + arguments: tc.arguments, + })); + messages.push(assistantMessageFromToolCalls(result.content, calls)); + + const outcome = await input.handleStep(calls); + if (outcome.kind === "conclude") { + return { + iterations: iteration, + stop: { kind: "conclude", call: outcome.call }, + }; + } + + for (const item of outcome.results) { + messages.push({ + role: "tool", + content: item.content, + tool_call_id: item.callId, + }); + } + } + + throw new Error(`Driver exceeded ${maxIterations} iterations`); + }, + }; +} diff --git a/src/runtime/llm-factory.ts b/src/runtime/llm-factory.ts index 26b0633..ea58219 100644 --- a/src/runtime/llm-factory.ts +++ b/src/runtime/llm-factory.ts @@ -1,23 +1,21 @@ import { profileToLlmConfig } from "../config/api-profiles.js"; import { ensureActiveProfileDefault, - loadAppSettings, resolveActiveProfile, } from "../config/settings.js"; -import { resolveActivePreset } from "../preset/store.js"; import { createMockMainAgentResponse, MockLlmProvider, OpenAiCompatibleProvider, type LlmProvider, } from "../llm/client.js"; -import { PresetLlmProvider } from "../llm/preset-wrapper.js"; +import type { PresetPackage } from "../types/preset.js"; import { - TokenTrackingProvider, - type LlmTrackingContext, -} from "../llm/token-tracker.js"; + wrapLlmForSession, + type LlmTrackingRef, +} from "../llm/preset-wrapper.js"; -export type LlmTrackingRef = { current: LlmTrackingContext }; +export type { LlmTrackingRef }; function buildInnerLlm(): LlmProvider { ensureActiveProfileDefault(); @@ -42,22 +40,11 @@ function buildInnerLlm(): LlmProvider { ]); } -function wrapWithPreset(inner: LlmProvider): LlmProvider { - const settings = loadAppSettings(); - const preset = resolveActivePreset(settings.activePresetId); - if (!preset) return inner; - return new PresetLlmProvider(inner, () => - resolveActivePreset(loadAppSettings().activePresetId), - ); -} - /** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */ export function createDefaultMainAgentLlm( trackingRef?: LlmTrackingRef, ): LlmProvider { - const llm = wrapWithPreset(buildInnerLlm()); - if (!trackingRef) return llm; - return new TokenTrackingProvider(llm, () => trackingRef.current); + return wrapLlmForSession(buildInnerLlm(), trackingRef); } export function hasRealLlmConfig(): boolean { @@ -76,3 +63,8 @@ export function reloadDefaultMainAgentLlm( ): LlmProvider { return createDefaultMainAgentLlm(trackingRef); } + +/** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */ +export function createLlmForPreset(preset: PresetPackage): LlmProvider { + return wrapLlmForSession(buildInnerLlm(), undefined, () => preset); +} diff --git a/src/runtime/orchestrator.ts b/src/runtime/orchestrator.ts index 223050f..1f7b00c 100644 --- a/src/runtime/orchestrator.ts +++ b/src/runtime/orchestrator.ts @@ -158,6 +158,8 @@ export class RuntimeOrchestrator { await this.runProgrammaticReview(effect.artifactId); break; case "invoke_main_agent": + case "propose_next_creation_step": + case "seal_creation_opening": break; } } diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index 8ac36f0..fb6d2ef 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -30,6 +30,12 @@ import { isModuleOpeningQuestions, normalizeQuestions, } from "../skills/question-protocol.js"; +import { + CREATION_SEALED_WAITING_MESSAGE, + SLOT_CREATION_SEALED_BY_OPENING, + isOpeningSealArtifact, +} from "../skills/opening-seal.js"; +import { inferLifecycleStage } from "../skills/worker-declaration.js"; import type { QuestionItem } from "../types/questions.js"; function nowIso(): string { @@ -81,7 +87,45 @@ function mergeSlotText( const trimmed = text.trim(); if (!trimmed) return; const prev = String(slots[key] ?? "").trim(); - slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed; + if (!prev) { + slots[key] = trimmed; + return; + } + if ( + prev === trimmed || + prev.includes(`\n\n${trimmed}`) || + prev.startsWith(`${trimmed}\n\n`) + ) { + return; + } + slots[key] = `${prev}\n\n${trimmed}`; +} + +/** 同一步内多次「按意见修改」叠意见;较新的写在后面 */ +function accumulateRevisionInstruction(prev: unknown, next: string): string { + const trimmed = next.trim(); + const old = typeof prev === "string" ? prev.trim() : ""; + if (!old || old === trimmed) return trimmed; + if (old.includes(trimmed)) return old; + return `${old}\n\n——\n\n${trimmed}`; +} + +/** 验收后把本步意见并入「用户.需求」按时间保留,再清掉本步槽位以免下一步误进修订 */ +function archiveStepNotesIntoDemand(slots: Record): void { + const reply = + typeof slots["用户.worker答复"] === "string" + ? slots["用户.worker答复"].trim() + : ""; + const note = + typeof slots["用户.修订说明"] === "string" + ? slots["用户.修订说明"].trim() + : ""; + if (reply) mergeSlotText(slots, "用户.需求", reply); + if (note) mergeSlotText(slots, "用户.需求", note); + delete slots["用户.修订说明"]; + delete slots.revisionInstruction; + delete slots.revisionTargetArtifactId; + delete slots["用户.worker答复"]; } /** 追加事件到 history 并 touch */ @@ -102,6 +146,30 @@ function running(session: RuntimeSession): RuntimeSession { return touch({ ...session, phase: "running", waitingReason: undefined }); } +/** 游玩连跑下一执行单元;创作则交总管 */ +function afterPlayOrMainAgent( + session: RuntimeSession, + next: RuntimeSession, +): ApplyEventResult { + const cleared = { + ...next, + pendingArtifactId: undefined, + currentWorkerId: undefined, + pendingDecision: undefined, + resumeContext: undefined, + }; + if (inferLifecycleStage(session) === "play") { + return { + session: touch(running(cleared)), + effects: [{ type: "continue_play_turn" }], + }; + } + return { + session: touch(running(cleared)), + effects: [{ type: "invoke_main_agent" }], + }; +} + function findArtifact( session: RuntimeSession, artifactId: string, @@ -150,11 +218,14 @@ function beginRevisionRerun( artifact: ArtifactRecord, instruction: string, ): ApplyEventResult { - const trimmed = instruction.trim(); + const trimmed = accumulateRevisionInstruction( + session.slots["用户.修订说明"] ?? session.slots.revisionInstruction, + instruction, + ); const slots: Record = { ...session.slots, "用户.修订说明": trimmed, - "用户.最新输入": trimmed, + "用户.最新输入": instruction.trim(), revisionInstruction: trimmed, // 本次重跑的底稿;重跑写不出新版时只许退回这一份 revisionTargetArtifactId: artifact.id, @@ -319,7 +390,10 @@ function handleWorkerCompleted( return fail(session, `Artifact not found: ${event.payload.artifactId}`); } - const mode = session.acceptanceMode ?? "user_confirmed"; + const mode = + inferLifecycleStage(session) === "play" + ? "no_confirmation" + : (session.acceptanceMode ?? "user_confirmed"); let next = appendHistory(session, event); if (next.slots.revisionTargetArtifactId) { @@ -346,16 +420,13 @@ function handleWorkerCompleted( if (mode === "no_confirmation") { next = updateArtifact(next, artifact.id, { status: "accepted" }); - return { - session: touch({ - ...running(next), - pendingArtifactId: undefined, - currentWorkerId: undefined, - pendingDecision: undefined, - resumeContext: undefined, - }), - effects: [{ type: "invoke_main_agent" }], - }; + return afterPlayOrMainAgent(session, { + ...next, + pendingArtifactId: undefined, + currentWorkerId: undefined, + pendingDecision: undefined, + resumeContext: undefined, + }); } // programmatic_review @@ -587,9 +658,8 @@ export function applyEvent( if (session.waitingReason?.kind === "worker_questions") { slots["用户.worker答复"] = text; if (text) slots["用户.最新输入"] = text; - if (demandKey && text) { - mergeSlotText(slots, demandKey, text); - } + // 开场/追问答复只进「用户.worker答复」,不追加进「用户.需求」 + // (否则后续步骤的用户表述会把各步答复叠成一份长转录,修订意见被淹没) } else if (session.waitingReason?.kind === "input" && text) { const key = demandKey || "用户.需求"; applyUserTextToSlots(slots, text, key, { @@ -615,6 +685,28 @@ export function applyEvent( }; } + if (inferLifecycleStage(session) === "play") { + return { + session: touch(running({ ...next, resumeContext: undefined })), + effects: [{ type: "run_play_turn" }], + }; + } + + if (session.slots[SLOT_CREATION_SEALED_BY_OPENING]) { + return { + session: waiting(touch({ ...next, resumeContext: undefined }), { + kind: "input", + message: CREATION_SEALED_WAITING_MESSAGE, + }), + effects: [ + { + type: "emit_message", + message: "创作已收口并保存。请切换到「游玩」开始。", + }, + ], + }; + } + // 常规定稿用户输入 → 等总管下一步 return { session: touch(running({ ...next, resumeContext: undefined })), @@ -817,7 +909,10 @@ export function applyEvent( case "worker_completed": { const result = handleWorkerCompleted(session, event); - const mode = session.acceptanceMode ?? "user_confirmed"; + const mode = + inferLifecycleStage(session) === "play" + ? "no_confirmation" + : (session.acceptanceMode ?? "user_confirmed"); if (mode === "user_confirmed" && result.session.pendingArtifactId) { const sidecar = asOptionalSidecarQuestions(event.payload.questions); const assessment = @@ -908,6 +1003,7 @@ export function applyEvent( return fail(session, `Artifact not found: ${event.payload.artifactId}`); } const slots: Record = { ...session.slots }; + archiveStepNotesIntoDemand(slots); // 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集"); if (isFinalSet) { @@ -924,24 +1020,29 @@ export function applyEvent( pendingDecision: undefined, currentWorkerId: undefined, }; - // 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑) - const gateNextIntent = - !isFinalSet && - (artifact.workerId === "design-step" || - artifact.workerId === "design-flow"); - if (gateNextIntent) { + // 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。 + if (isOpeningSealArtifact(artifact)) { return { session: waiting(cleared, { - kind: "next_intent", - afterWorkerId: artifact.workerId, + kind: "input", + message: CREATION_SEALED_WAITING_MESSAGE, }), - effects: [ - { - type: "emit_message", - message: - "这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。", - }, - ], + effects: [{ type: "seal_creation_opening" }], + }; + } + if (inferLifecycleStage(session) === "play") { + return afterPlayOrMainAgent(session, cleared); + } + // 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。 + // 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。 + // 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。 + const proposeNext = + artifact.workerId === "design-step" || + artifact.workerId === "design-flow"; + if (proposeNext) { + return { + session: touch(running(cleared)), + effects: [{ type: "propose_next_creation_step" }], }; } return { @@ -1029,7 +1130,10 @@ export function applyEvent( session: touch( running({ ...next, pendingArtifactId: undefined }), ), - effects: [{ type: "invoke_main_agent" }], + effects: + inferLifecycleStage(session) === "play" + ? [{ type: "continue_play_turn" }] + : [{ type: "invoke_main_agent" }], }; } diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index a0dbbab..2efd64c 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -62,6 +62,7 @@ import { CREATION_MODULE_OPENING_STATE_TAG, SLOT_CREATION_MODULE_OPENING_STATE, loadModuleCatalog, + isReviseStep, mergeCreationFlowPreservingAccepted, nextPendingStep, parseCreationFlow, @@ -71,10 +72,12 @@ import { resolveDesignStepBinding, shouldSkipModuleOpening, isProgressPointerTag, + missingRequiredStepParams, stepUnitId, stringifyCreationFlow, stringifyModuleOpeningState, type CreationFlowStepParams, + type ModuleCatalogEntry, type ModuleParamSpec, } from "../skills/creation-flow.js"; import { @@ -86,6 +89,22 @@ import { SLOT_ASKED_QUESTIONS, } from "../skills/question-protocol.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { + closeCreationFlowRaw, + CREATION_SEALED_WAITING_MESSAGE, + mergeOpeningTablePatch, + OPENING_CURRENT_VARS_TAG, + OPENING_INITIAL_VARS_TAG, + OPENING_OUTPUT_TAG, + OPENING_SETUP_ARTIFACT_TAG, + parseOpeningSealPayload, + SLOT_CREATION_SEALED_BY_OPENING, + SLOT_OPENING_SELECTED_INDEX, +} from "../skills/opening-seal.js"; +import { + readPlayTurnQueue, + withPlayTurnQueue, +} from "../skills/play-turn.js"; import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js"; import { executeChance, @@ -272,6 +291,26 @@ export class PhaseRuntime { this.lastWorkerRunSnapshot = null; } + /** + * 执行中卡住:有进行中的 worker 则按开跑快照重跑;否则重跑总管。 + */ + async retryStuckRun(): Promise { + const workerId = this.session.currentWorkerId; + const snap = this.lastWorkerRunSnapshot; + if (workerId && snap && snap.workerId === workerId) { + this.restoreFromCheckpoint( + snap.runtimeSession, + snap.blackboardItems, + ); + return this.rerunWorker(workerId); + } + if (this.session.phase === "running" && !this.session.waitingReason) { + await this.runMainAgent(); + return this.session; + } + throw new Error("当前没有可重试的执行"); + } + /** 重 roll 指定 worker(刷新 Skill 回复) */ async rerunWorker(workerId: string): Promise { const effect: Extract = { @@ -575,15 +614,38 @@ export class PhaseRuntime { } /** 用户接受 pendingArtifact;创作单位验收 vs 终稿验收分流 */ - async acceptArtifact(artifactId?: string): Promise { + async acceptArtifact( + artifactId?: string, + opts?: { openingIndex?: number }, + ): Promise { const id = artifactId ?? this.session.pendingArtifactId; if (!id) throw new Error("当前没有待验收的产物"); const artifact = this.session.artifacts.find((a) => a.id === id); + if (artifact && opts?.openingIndex != null && Number.isFinite(opts.openingIndex)) { + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_OPENING_SELECTED_INDEX]: Math.trunc(opts.openingIndex), + }, + }; + } + const unitAccept = artifact ? isDesignUnitArtifact(artifact) : false; + // 细化终稿写「设计.worker集」,不是单位草稿,但仍须记入已验收步骤, + // 否则编排器会当成没做完再跑一遍。须在 dispatch 之前写入。 + const shouldRecordStep = Boolean( + artifact && (unitAccept || artifact.workerId === "design-step"), + ); + if (artifact && shouldRecordStep) { + const slots = { ...this.session.slots }; + this.recordCreationUnitAccepted(slots, artifact); + this.session = { ...this.session, slots }; + } + await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } }); this.persistInstanceWorkerDeclaration(); if (artifact) { - const unitAccept = isDesignUnitArtifact(artifact); const result = compressAfterWorkerAccept({ blackboard: this.blackboard, workerId: artifact.workerId, @@ -598,8 +660,7 @@ export class PhaseRuntime { lastCompressedWorkerId: artifact.workerId, }; - if (unitAccept) { - this.recordCreationUnitAccepted(slots, artifact); + if (shouldRecordStep) { const unitId = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); this.onMessage( `[创作单位已验收] ${unitId || artifact.workerId}:${ @@ -608,7 +669,8 @@ export class PhaseRuntime { ); slots[SLOT_CREATION_CURRENT_UNIT] = undefined; slots[SLOT_CREATION_UNIT_ANCHOR_AT] = undefined; - } else { + } + if (!unitAccept) { this.onMessage( `[上下文已压缩] ${artifact.workerId}:保留 ${result.finals.map((f) => f.tag).join("、") || "(无终产物)"};归档 ${result.archivedTags.length} 个过程 tag。下一阶段以定稿为准。`, ); @@ -814,6 +876,15 @@ export class PhaseRuntime { case "propose_next_creation_step": await this.proposeNextCreationStep(); break; + case "seal_creation_opening": + this.sealCreationOpening(); + break; + case "run_play_turn": + await this.startPlayTurn(); + break; + case "continue_play_turn": + await this.continuePlayTurn(); + break; } } } @@ -822,6 +893,10 @@ export class PhaseRuntime { * 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。 */ private async proposeNextCreationStep(): Promise { + if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) { + this.onMessage(CREATION_SEALED_WAITING_MESSAGE); + return; + } this.syncSlotsToBlackboard(this.session); const flow = parseCreationFlow( this.blackboard.getContentByTag(CREATION_FLOW_TAG), @@ -853,6 +928,9 @@ export class PhaseRuntime { const active = this.getActiveSkill(); let paramSpecs: ModuleParamSpec[] = []; let declaration = ""; + let auto = false; + let kind: ModuleCatalogEntry["kind"]; + let pendingModule: ModuleCatalogEntry | null = null; if (active?.name) { try { const skill = await loadSkill(active.name); @@ -861,6 +939,9 @@ export class PhaseRuntime { const mod = catalog?.modules.find((m) => m.name === pending.name); paramSpecs = mod?.params ?? []; declaration = mod?.declaration?.trim() || ""; + auto = mod?.auto === true; + kind = mod?.kind; + pendingModule = mod ?? null; } } catch { /* catalog optional */ @@ -870,13 +951,7 @@ export class PhaseRuntime { debugLog("step", `提案下一步 ${pending.id}(${pending.name})`); const params = pending.params ? { ...pending.params } : {}; - const paramsMissing = paramSpecs - .filter((p) => p.required) - .map((p) => p.key) - .filter((key) => { - const v = params[key]; - return v == null || (typeof v === "string" && !v.trim()); - }); + const paramsMissing = missingRequiredStepParams(pending, pendingModule); const intent = typeof this.session.slots["用户.下一步意向"] === "string" @@ -890,8 +965,9 @@ export class PhaseRuntime { : paramsMissing.length ? `;待钉参数:${paramsMissing.join("、")}` : ""; + const revise = isReviseStep(pending); const reason = [ - `下一步:${pending.name}`, + revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`, declaration ? `—— ${declaration}` : "", paramHint, intent ? `(你的意向:${intent})` : "", @@ -912,6 +988,9 @@ export class PhaseRuntime { hint: p.hint, })), intent: intent || undefined, + ...(kind ? { kind } : {}), + ...(revise ? { mode: "revise" as const } : {}), + ...(pending.revises ? { revises: pending.revises } : {}), }; this.blackboard.write({ tag: CREATION_PROPOSED_STEP_TAG, @@ -930,7 +1009,8 @@ export class PhaseRuntime { action: "run_worker", workerId: "design-step", reason, - requiresApproval: true, + // 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。 + requiresApproval: !(auto && paramsMissing.length === 0), }); await this.dispatch({ type: "main_agent_decision_created", @@ -938,6 +1018,81 @@ export class PhaseRuntime { }); } + /** + * 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。 + */ + private sealCreationOpening(): void { + const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX]; + const selectedIndex = + typeof selectedRaw === "number" + ? selectedRaw + : typeof selectedRaw === "string" && selectedRaw.trim() + ? Number(selectedRaw) + : 0; + + const fragmentRaw = + this.blackboard.getContentByTag(OPENING_SETUP_ARTIFACT_TAG) ?? ""; + const payload = parseOpeningSealPayload(fragmentRaw, selectedIndex); + const existingOpening = this.blackboard.getContentByTag(OPENING_OUTPUT_TAG)?.trim(); + const openingText = payload?.selectedText?.trim() || existingOpening || ""; + + if (openingText) { + this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup"); + } + + if (payload?.variables.length) { + const source = "worker:opening-setup"; + const initialPatch = mergeOpeningTablePatch( + this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG), + payload.variables, + source, + ); + if (initialPatch) { + this.writeWorkerTagContent(OPENING_INITIAL_VARS_TAG, initialPatch, "opening-setup"); + } + const currentPatch = mergeOpeningTablePatch( + this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG), + payload.variables, + source, + ); + if (currentPatch) { + this.writeWorkerTagContent(OPENING_CURRENT_VARS_TAG, currentPatch, "opening-setup"); + } + } + + const closed = closeCreationFlowRaw( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ); + if (closed) { + this.blackboard.write({ + tag: CREATION_FLOW_TAG, + content: closed, + source: "runtime", + }); + } + + const n = payload?.candidates.length ?? (openingText ? 1 : 0); + const which = + n > 1 && payload + ? `第 ${payload.selectedIndex + 1}/${n} 条` + : n + ? "开场白" + : "开场"; + this.onMessage( + `[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`, + ); + + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_CREATION_SEALED_BY_OPENING]: true, + [CREATION_FLOW_TAG]: closed ?? this.session.slots[CREATION_FLOW_TAG], + [OPENING_OUTPUT_TAG]: openingText || this.session.slots[OPENING_OUTPUT_TAG], + }, + }; + } + private applyProposedStepParams(params: CreationFlowStepParams): void { const raw = (typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string" @@ -947,6 +1102,7 @@ export class PhaseRuntime { if (!raw?.trim()) return; let snap: { stepId?: string; + kind?: string; paramSpecs?: Array<{ key: string; required?: boolean }>; }; try { @@ -967,13 +1123,16 @@ export class PhaseRuntime { content, source: "user", }); - const paramsMissing = (snap.paramSpecs ?? []) - .filter((p) => p.required) - .map((p) => p.key) - .filter((key) => { - const v = params[key]; - return v == null || (typeof v === "string" && !v.trim()); - }); + const paramsMissing = + snap.kind === "prior-artifact" + ? [] + : (snap.paramSpecs ?? []) + .filter((p) => p.required) + .map((p) => p.key) + .filter((key) => { + const v = params[key]; + return v == null || (typeof v === "string" && !v.trim()); + }); const snapshot = { ...snap, params, @@ -1122,6 +1281,7 @@ export class PhaseRuntime { this.onMessage( `[Worker] ${workerId} 执行中…${resolved.source === "declaration" ? "(声明驱动)" : ""}`, ); + debugLog("step", `调用模型 ${workerId}`); this.onWorkerStreamStart?.(workerId); try { @@ -1131,10 +1291,7 @@ export class PhaseRuntime { slots, blackboard: this.blackboard, llm: workerLlm, - declared: - resolved.source === "declaration" - ? { worker: resolved.worker, promptBody: resolved.promptBody } - : undefined, + declared: { worker: resolved.worker, promptBody: resolved.promptBody }, stream: { onThinkingDelta: (delta) => this.onWorkerThinkingDelta?.(workerId, delta), @@ -1193,8 +1350,13 @@ export class PhaseRuntime { }); this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; + const hasPlayVisible = Object.keys(result.outputs).some( + (t) => t === "输出.用户展示" || t === "输出.开场白", + ); this.onMessage( - `[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`, + `[Worker] ${workerId} 已完成\n\n${result.preview}${ + !hasPlayVisible && result.preview.length >= 4000 ? "\n\n…" : "" + }`, ); await this.dispatch({ @@ -1907,8 +2069,59 @@ export class PhaseRuntime { ); } + /** 游玩:按 playWorkerIds 开一轮 */ + private async startPlayTurn(): Promise { + this.syncSlotsToBlackboard(this.session); + const ids = this.getInstanceWorkerDeclaration().playWorkerIds; + if (!ids.length) { + this.onMessage( + "[游玩] 运行规格里没有可上场的执行单元,无法推进回合。", + ); + this.waitForPlayInput(); + return; + } + const [first, ...rest] = ids; + this.session = { + ...this.session, + slots: withPlayTurnQueue(this.session.slots, rest), + }; + await this.processEffects([{ type: "run_worker", workerId: first }]); + } + + /** 游玩:跑管线下一个;队空则等用户 */ + private async continuePlayTurn(): Promise { + if (this.session.phase === "waiting_user") return; + const queue = readPlayTurnQueue(this.session.slots); + if (!queue.length) { + this.waitForPlayInput(); + return; + } + const [next, ...rest] = queue; + this.session = { + ...this.session, + slots: withPlayTurnQueue(this.session.slots, rest), + }; + await this.processEffects([{ type: "run_worker", workerId: next }]); + } + + private waitForPlayInput(): void { + this.session = { + ...this.session, + phase: "waiting_user", + waitingReason: { kind: "input" }, + currentWorkerId: undefined, + pendingArtifactId: undefined, + pendingDecision: undefined, + resumeContext: undefined, + slots: withPlayTurnQueue(this.session.slots, undefined), + updatedAt: new Date().toISOString(), + }; + } + /** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */ private async maybeRunMainAgent(effects: PhaseEffect[]): Promise { + if (inferLifecycleStage(this.session) === "play") return; + if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return; if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) { await this.runMainAgent(); } diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index 887255c..be29a3f 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -399,7 +399,10 @@ export function buildFocus( return { actorType: "user", actorLabel: "你", - action: "补充说明", + action: + reason.message && /创作已收口/.test(reason.message) + ? "创作已收口" + : "补充说明", detail: reason.message?.slice(0, 120), }; } @@ -412,6 +415,7 @@ export function buildFocus( try { return JSON.parse(String(session.slots["创作.待确认步骤"])) as { name?: string; + mode?: string; }; } catch { return null; @@ -423,7 +427,9 @@ export function buildFocus( actorId: "orchestrator", actorLabel: "编排", action: proposed?.name - ? `确认开始 · ${proposed.name}` + ? proposed.mode === "revise" + ? `回头修改 · ${proposed.name}` + : `确认开始 · ${proposed.name}` : `建议调用 ${displayWorkerLabel(worker)}`, detail: session.pendingDecision?.reason, }; @@ -467,6 +473,7 @@ export function buildFocus( const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); const copy = reviewComposerCopy(art?.workerId, { hasQuestions: Boolean(reason.questions?.length), + outputTags: art?.outputTags, }); const optionalQs = reason.questions?.length ? `;另有 ${reason.questions.length} 道可选追问` diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts index b2da090..1b53926 100644 --- a/src/server/display-labels.ts +++ b/src/server/display-labels.ts @@ -146,10 +146,13 @@ export type ReviewComposerCopy = { */ export function reviewComposerCopy( workerId: string | undefined | null, - opts?: { hasQuestions?: boolean }, + opts?: { hasQuestions?: boolean; outputTags?: readonly string[] }, ): ReviewComposerCopy { const hasQuestions = Boolean(opts?.hasQuestions); const id = (workerId ?? "").trim(); + const openingReview = + id === "opening-generator" || + (opts?.outputTags ?? []).includes("设计.开场白与开场变量"); if (id === "design-flow") { return { @@ -169,7 +172,7 @@ export function reviewComposerCopy( }; } - if (id === "opening-generator") { + if (id === "opening-generator" || openingReview) { return { chip: "开场", kicker: "待选定", diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 4313b25..23dc4a5 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -10,6 +10,22 @@ import { import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js"; import { toRunSnapshotMeta } from "../types/run-snapshot.js"; import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js"; +import { + CREATION_SEALED_WAITING_MESSAGE, + INSTANCE_OPENING_SNAPSHOT_LABEL, + OPENING_OUTPUT_TAG, + SLOT_CREATION_SEALED_BY_OPENING, +} from "../skills/opening-seal.js"; +import { + PLAY_WORKING_SNAPSHOT_ID, + PLAY_WORKING_SNAPSHOT_LABEL, + SLOT_PLAY_LAYER_ACTIVE, + isPlayLayerActive, +} from "../skills/play-turn.js"; +import { + DIALOGUE_HISTORY_TAG, + appendDialogueHistoryTurn, +} from "../skills/dialogue-history.js"; import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js"; import { createSession } from "../runtime/phase-machine.js"; import { @@ -18,6 +34,7 @@ import { reloadDefaultMainAgentLlm, type LlmTrackingRef, } from "../runtime/llm-factory.js"; +import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js"; import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js"; import type { PersistedBookSession } from "../types/book-session.js"; import type { RuntimeSession, WaitingReason } from "../types/runtime.js"; @@ -179,6 +196,8 @@ export type SessionView = { /** @deprecated 用 skillCatalog */ pipeline: StageStep[]; lifecycleStage: LifecycleStage; + /** 已从定稿开出聊天层(不是只把顶栏标成游玩) */ + playLayerActive: boolean; playReady: boolean; skillCatalog: SkillCatalogEntry[]; toolTrace: ToolTraceEntry[]; @@ -271,7 +290,13 @@ export type ProposedNextStepView = { required: boolean; hint?: string; }>; + /** 节点特性:prior-artifact = 先验产物(不在确认开干时钉「写什么」) */ + kind?: string; + /** 用户在「下一步想写什么」里写的意向(与步骤 mode 不同) */ intent?: string; + /** 步骤怎么跑:revise = 回头修改既有产物 */ + mode?: "fresh" | "revise"; + revises?: string; }; export type ReviewArtifactView = { @@ -279,6 +304,7 @@ export type ReviewArtifactView = { workerId: string; summary?: string; body: string; + outputTags?: string[]; /** 产出该产物的 worker_output 消息(验收态下 feed 会隐藏该消息) */ sourceMessageId?: string; /** 与 sourceMessage 同源的全量 LLM 上下文,供待验收卡片右键查看 */ @@ -301,6 +327,12 @@ type ManagedSession = { recipeOptions?: RecipeCatalogEntry[]; /** 开局模块 opening,缓存给意图页 */ openingGuide?: { stepId: string; stepName: string; text: string } | null; + /** 当前 LLM 请求的取消器(停止并重试) */ + runAbort?: AbortController; + /** 递增世代:后一次请求作废前一次 */ + runGen?: number; + /** 本轮开始时的消息条数,取消时裁掉执行中产生的调度句 */ + runMessageCutoff?: number; }; export class SessionManager { @@ -327,7 +359,11 @@ export class SessionManager { if (stage === "play" && !canEnterPlay(session)) { throw new Error("实例尚未就绪,无法进入游玩"); } - s.runtime.setLifecycleStage(stage); + if (stage === "play") { + this.enterPlayLayer(s, { resume: true }); + } else { + this.enterDesignLayer(s); + } if (s.bookId) this.persist(s); return this.toView(sessionId); } @@ -582,10 +618,10 @@ export class SessionManager { return toRunSnapshotMeta(snapshot); } - /** 列出某 Book 的全部存档 */ + /** 列出某 Book 的全部存档(不含内部「当前游玩」工作副本) */ listGameSnapshots(bookId: string): RunSnapshotMeta[] { if (!getBook(bookId)) throw new Error("Book 不存在"); - return listRunSnapshots(bookId); + return listRunSnapshots(bookId).filter((s) => s.id !== PLAY_WORKING_SNAPSHOT_ID); } /** 仅游玩 run 存档(UI 抽屉用) */ @@ -604,7 +640,7 @@ export class SessionManager { if (!canEnterPlay(session)) { throw new Error("须先验收 Worker 集,才能保存游玩存档"); } - if (inferLifecycleStage(session) !== "play") { + if (inferLifecycleStage(session) !== "play" || !isPlayLayerActive(session.slots)) { throw new Error("请先切换到「游玩」再保存游玩存档"); } return this.saveGameSnapshot(sessionId, label, "run", note); @@ -618,16 +654,97 @@ export class SessionManager { if (!canEnterPlay(session)) { throw new Error("须先验收 Worker 集,才能开始游玩"); } + this.archivePlayWorking(s.bookId); + this.enterPlayLayer(s, { resume: false }); + this.persist(s); + return this.toView(sessionId); + } - let runtimeSession = structuredClone(session); + /** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */ + private enterPlayLayer( + s: ManagedSession, + opts: { resume: boolean }, + ): void { + const session = s.runtime.getSession(); + if (!canEnterPlay(session)) { + throw new Error("实例尚未就绪,无法进入游玩"); + } + if ( + opts.resume && + isPlayLayerActive(session.slots) && + inferLifecycleStage(session) === "play" + ) { + this.sanitizePlayHitl(s); + return; + } + if (s.bookId) this.ensureInstanceSnapshot(s); + if (opts.resume && s.bookId) { + const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID); + if (working?.kind === "run") { + this.restoreSnapshotInPlace(s, working, "play"); + return; + } + } + this.beginNewPlayFromInstance(s); + if (s.bookId) this.savePlayWorking(s); + } + + /** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */ + private enterDesignLayer(s: ManagedSession): void { + const session = s.runtime.getSession(); + if ( + inferLifecycleStage(session) === "design" && + !isPlayLayerActive(session.slots) + ) { + s.runtime.setLifecycleStage("design"); + return; + } + if (s.bookId && isPlayLayerActive(session.slots)) { + this.savePlayWorking(s); + } + const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null; + if (instance) { + this.restoreSnapshotInPlace(s, instance, "design"); + return; + } + let runtimeSession = structuredClone(s.runtime.getSession()); let blackboardItems = s.runtime.getBlackboard().exportItems(); ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ runtimeSession, blackboardItems, })); + runtimeSession.id = session.id; + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "design", + [SLOT_PLAY_LAYER_ACTIVE]: undefined, + }; + runtimeSession.phase = "waiting_user"; + runtimeSession.waitingReason = { kind: "input" }; + s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems); + } + + private beginNewPlayFromInstance(s: ManagedSession): void { + const keepId = s.runtime.getSession().id; + let runtimeSession: RuntimeSession; + let blackboardItems: import("../types/blackboard.js").BlackboardItem[]; + const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null; + if (instance) { + runtimeSession = structuredClone(instance.runtimeSession); + blackboardItems = structuredClone(instance.blackboardItems); + } else { + runtimeSession = structuredClone(s.runtime.getSession()); + blackboardItems = s.runtime.getBlackboard().exportItems(); + } + ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ + runtimeSession, + blackboardItems, + })); + runtimeSession.id = keepId; runtimeSession.slots = { ...runtimeSession.slots, uiLifecycleStage: "play", + [SLOT_PLAY_LAYER_ACTIVE]: true, startupCompleted: true, }; runtimeSession.phase = "waiting_user"; @@ -636,10 +753,166 @@ export class SessionManager { runtimeSession.pendingDecision = undefined; runtimeSession.currentWorkerId = undefined; runtimeSession.resumeContext = undefined; - s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems); - this.persist(s); - return this.toView(sessionId); + const opening = + s.runtime.getBlackboard().getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; + if (opening) { + s.runtime.getBlackboard().write({ + tag: DIALOGUE_HISTORY_TAG, + content: appendDialogueHistoryTurn("", { role: "助手", text: opening }), + source: "runtime", + }); + s.messages = [this.playOpeningMessage(opening)]; + } else { + s.messages = []; + } + s.branchState = createMessageBranchState(); + } + + private restoreSnapshotInPlace( + s: ManagedSession, + snapshot: RunSnapshot, + layer: LifecycleStage, + ): void { + const keepId = s.runtime.getSession().id; + let runtimeSession = structuredClone(snapshot.runtimeSession); + let blackboardItems = structuredClone(snapshot.blackboardItems); + if (snapshot.kind === "instance" || layer === "design") { + ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ + runtimeSession, + blackboardItems, + })); + } + runtimeSession.id = keepId; + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: layer, + [SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined, + startupCompleted: true, + }; + if (layer === "play") { + runtimeSession.phase = runtimeSession.phase === "done" + ? "waiting_user" + : runtimeSession.phase; + if (!runtimeSession.waitingReason) { + runtimeSession.waitingReason = { kind: "input" }; + } + } else { + const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]); + runtimeSession.phase = "waiting_user"; + runtimeSession.waitingReason = { + kind: "input", + ...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}), + }; + runtimeSession.pendingArtifactId = undefined; + runtimeSession.pendingDecision = undefined; + runtimeSession.currentWorkerId = undefined; + } + s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems); + s.messages = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[]; + s.branchState = createMessageBranchState(); + if (layer === "play") this.sanitizePlayHitl(s); + } + + /** 游玩层不保留创作 HITL(验收 / 确认下一步 / 修订) */ + private sanitizePlayHitl(s: ManagedSession): void { + const session = s.runtime.getSession(); + const play = + isPlayLayerActive(session.slots) || inferLifecycleStage(session) === "play"; + if (!play) return; + const kind = session.waitingReason?.kind; + if ( + kind !== "review_artifact" && + kind !== "approve_step" && + kind !== "revision" && + kind !== "next_intent" + ) { + return; + } + session.phase = "waiting_user"; + session.waitingReason = { kind: "input" }; + session.pendingArtifactId = undefined; + session.pendingDecision = undefined; + session.currentWorkerId = undefined; + session.resumeContext = undefined; + session.updatedAt = new Date().toISOString(); + } + + private savePlayWorking(s: ManagedSession): void { + if (!s.bookId) return; + const book = getBook(s.bookId); + const runtimeSession = structuredClone(s.runtime.getSession()); + const snapshot: RunSnapshot = { + version: 1, + id: PLAY_WORKING_SNAPSHOT_ID, + bookId: s.bookId, + label: PLAY_WORKING_SNAPSHOT_LABEL, + kind: "run", + orchestratorId: + sessionSkillPackId(runtimeSession) ?? + (book ? bookSkillPackId(book) : "") ?? + "", + runtimeSession, + blackboardItems: s.runtime.getBlackboard().exportItems(), + messages: s.messages.map((m) => ({ ...m })), + createdAt: new Date().toISOString(), + note: "切换创作/游玩时自动保存的当前聊天", + }; + saveRunSnapshotFile(snapshot); + } + + private archivePlayWorking(bookId: string): void { + const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID); + if (!working) return; + if (!working.messages.some((m) => m.role === "user")) return; + const stamped: RunSnapshot = { + ...working, + id: randomUUID(), + label: `游玩 ${new Date().toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })}`, + createdAt: new Date().toISOString(), + note: "新建游玩前自动保存", + }; + saveRunSnapshotFile(stamped); + } + + private ensureInstanceSnapshot(s: ManagedSession): void { + if (!s.bookId) return; + if (this.latestInstanceSnapshot(s.bookId)) return; + if (!canEnterPlay(s.runtime.getSession())) return; + try { + this.saveGameSnapshot( + s.runtime.getSession().id, + INSTANCE_OPENING_SNAPSHOT_LABEL, + "instance", + "进入游玩前自动保存定稿", + ); + } catch (err) { + console.warn("[会话] 进入游玩前保存定稿失败", err); + } + } + + private latestInstanceSnapshot(bookId: string): RunSnapshot | null { + const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance"); + if (!meta) return null; + return loadRunSnapshotFile(bookId, meta.id); + } + + private playOpeningMessage(text: string): ChatMessage { + return { + id: randomUUID(), + role: "system", + text, + createdAt: new Date().toISOString(), + kind: "system_info", + actor: "narrator", + title: "开场白", + body: text, + }; } /** 从存档读档:替换当前作品进度,可继续创作 */ @@ -665,10 +938,16 @@ export class SessionManager { runtimeSession, blackboardItems, })); + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "design", + [SLOT_PLAY_LAYER_ACTIVE]: undefined, + }; } else { runtimeSession.slots = { ...runtimeSession.slots, uiLifecycleStage: "play", + [SLOT_PLAY_LAYER_ACTIVE]: true, }; } @@ -725,6 +1004,16 @@ export class SessionManager { opts?: { answers?: QuestionAnswer[] }, ): Promise { const s = this.require(id); + this.sanitizePlayHitl(s); + const waitingBefore = s.runtime.getSession().waitingReason; + if ( + inferLifecycleStage(s.runtime.getSession()) === "play" && + !isPlayLayerActive(s.runtime.getSession().slots) && + waitingBefore?.kind === "input" + ) { + this.enterPlayLayer(s, { resume: true }); + if (s.bookId) this.persist(s); + } this.clearAgentThinking(id); recordPreMessageCheckpoint( s.branchState, @@ -764,35 +1053,36 @@ export class SessionManager { if (s.bookId) this.syncBookPreview(s.bookId, s.messages); } try { - if (reason?.kind === "approve_step") { - await s.runtime.rejectStep(trimmed || text); - } else if (reason?.kind === "review_artifact") { - // 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物 - if (sidecarAnswers.length && reason.questions?.length) { - const qaText = formatQuestionAnswersForAi( - normalizeQuestions(reason.questions), - sidecarAnswers, - trimmed || undefined, - ); - await s.runtime.resolveSidecarQuestions(qaText); + const owned = await this.runExclusive(id, s, async () => { + if (reason?.kind === "approve_step") { + await s.runtime.rejectStep(trimmed || text); + } else if (reason?.kind === "review_artifact") { + // 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物 + if (sidecarAnswers.length && reason.questions?.length) { + const qaText = formatQuestionAnswersForAi( + normalizeQuestions(reason.questions), + sidecarAnswers, + trimmed || undefined, + ); + await s.runtime.resolveSidecarQuestions(qaText); + } + if (trimmed || sidecarAnswers.length) { + await s.runtime.rejectArtifact( + trimmed || "按追问作答更新产物", + ); + } + } else { + await s.runtime.submitInput(trimmed || text); } - if (trimmed || sidecarAnswers.length) { - await s.runtime.rejectArtifact( - trimmed || "按追问作答更新产物", - ); - } - } else { - await s.runtime.submitInput(trimmed || text); - } + }); + 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))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); - } finally { - this.clearAgentThinking(id); - this.syncCreationDialogue(s); } + this.syncCreationDialogue(s); this.persist(s); return this.toView(id); } @@ -838,22 +1128,23 @@ export class SessionManager { if (s.bookId) this.syncBookPreview(s.bookId, s.messages); try { - if (isSidecar) { - await s.runtime.resolveSidecarQuestions(aiText); - // 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转 - await s.runtime.rejectArtifact("按追问作答更新产物"); - } else { - await s.runtime.submitInput(aiText); - } + const owned = await this.runExclusive(id, s, async () => { + if (isSidecar) { + await s.runtime.resolveSidecarQuestions(aiText); + // 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转 + await s.runtime.rejectArtifact("按追问作答更新产物"); + } else { + await s.runtime.submitInput(aiText); + } + }); + 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))); if (s.bookId) this.syncBookPreview(s.bookId, s.messages); - } finally { - this.clearAgentThinking(id); - this.syncCreationDialogue(s); } + this.syncCreationDialogue(s); this.persist(s); return this.toView(id); } @@ -872,7 +1163,10 @@ export class SessionManager { this.clearAgentThinking(id); s.messages.push(this.msg("user", "跳过可选追问")); try { - await s.runtime.resolveSidecarQuestions(); + const owned = await this.runExclusive(id, s, () => + s.runtime.resolveSidecarQuestions(), + ); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); s.messages.push(this.msg("system", formatRuntimeError(detail))); @@ -918,7 +1212,10 @@ export class SessionManager { if (s.bookId) this.syncBookPreview(s.bookId, s.messages); try { - await s.runtime.submitInput(trimmed); + const owned = await this.runExclusive(id, s, () => + s.runtime.submitInput(trimmed), + ); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); s.messages.push(this.msg("system", formatRuntimeError(detail))); @@ -974,7 +1271,10 @@ export class SessionManager { if (s.bookId) this.syncBookPreview(s.bookId, s.messages); try { - await s.runtime.rerunWorker(workerId); + const owned = await this.runExclusive(id, s, () => + s.runtime.rerunWorker(workerId), + ); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); s.messages.push(this.msg("system", formatRuntimeError(detail))); @@ -997,6 +1297,32 @@ export class SessionManager { return this.toView(id); } + /** 中止当前生成并重跑卡住的 worker / 总管 */ + async abortAndRetry(id: string): Promise { + const s = this.require(id); + const session = s.runtime.getSession(); + if (session.phase !== "running" || session.waitingReason) { + throw new Error("当前没有正在执行的任务"); + } + const cutoff = s.runMessageCutoff ?? s.messages.length; + s.messages = s.messages.slice(0, cutoff); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + try { + const owned = await this.runExclusive(id, s, async () => { + await s.runtime.retryStuckRun(); + }); + 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))); + if (s.bookId) this.syncBookPreview(s.bookId, s.messages); + } + this.syncCreationDialogue(s); + this.persist(s); + return this.toView(id); + } + /** 删除消息及其后的对话 */ async deleteMessage(id: string, messageId: string): Promise { const s = this.require(id); @@ -1053,7 +1379,10 @@ export class SessionManager { ): Promise { const s = this.require(id); try { - await s.runtime.approve(stepParams); + const owned = await this.runExclusive(id, s, () => + s.runtime.approve(stepParams), + ); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); console.error("[会话] 确认开干失败", detail); @@ -1065,7 +1394,10 @@ export class SessionManager { async confirmIntake(id: string): Promise { const s = this.require(id); try { - await s.runtime.confirmIntake(); + const owned = await this.runExclusive(id, s, () => + s.runtime.confirmIntake(), + ); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); console.error("[会话] 确认需求失败", detail); @@ -1075,7 +1407,10 @@ export class SessionManager { return this.toView(id); } - async accept(id: string): Promise { + async accept( + id: string, + opts?: { openingIndex?: number }, + ): Promise { const s = this.require(id); const pendingId = s.runtime.getSession().pendingArtifactId; const waiting = s.runtime.getSession().waitingReason; @@ -1085,7 +1420,21 @@ export class SessionManager { ? s.runtime.getSession().artifacts.find((a) => a.id === pendingId) : undefined; const stageBefore = inferLifecycleStage(s.runtime.getSession()); - await s.runtime.acceptArtifact(); + const sealedBefore = Boolean( + s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING], + ); + try { + const owned = await this.runExclusive(id, s, () => + s.runtime.acceptArtifact(undefined, opts), + ); + 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); + } // 接受产物 = 不再用追问完善;询问模块随 waitingReason 清除而收起 if (hadSidecarQuestions) { s.messages.push( @@ -1101,6 +1450,8 @@ export class SessionManager { } } this.persist(s); + this.maybeSaveOpeningInstance(s, sealedBefore); + this.persist(s); return this.toView(id); } @@ -1109,13 +1460,16 @@ export class SessionManager { const s = this.require(id); const waiting = s.runtime.getSession().waitingReason; try { - if (waiting?.kind === "approve_step") { - await s.runtime.rejectStep(reason ?? "用户暂不执行"); - } else if (waiting?.kind === "review_artifact") { - await s.runtime.rejectArtifact(reason ?? "用户要求重新来"); - } else { - throw new Error("当前没有可拒绝的确认或验收"); - } + const owned = await this.runExclusive(id, s, async () => { + if (waiting?.kind === "approve_step") { + await s.runtime.rejectStep(reason ?? "用户暂不执行"); + } else if (waiting?.kind === "review_artifact") { + await s.runtime.rejectArtifact(reason ?? "用户要求重新来"); + } else { + throw new Error("当前没有可拒绝的确认或验收"); + } + }); + if (!owned) return this.toView(id); } catch (err) { const detail = err instanceof Error ? err.message : String(err); console.error("[会话] 打回失败", detail); @@ -1127,26 +1481,42 @@ export class SessionManager { async runOutline(id: string): Promise { const s = this.require(id); - await s.runtime.submitDecision( - createDecision({ - action: "run_worker", - reason: "根据创作简报生成大纲", - workerId: "outline", - requiresApproval: true, - }), - ); + try { + const owned = await this.runExclusive(id, s, () => + s.runtime.submitDecision( + createDecision({ + action: "run_worker", + reason: "根据创作简报生成大纲", + workerId: "outline", + requiresApproval: true, + }), + ), + ); + if (!owned) return this.toView(id); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } return this.toView(id); } async finish(id: string): Promise { const s = this.require(id); - await s.runtime.submitDecision( - createDecision({ - action: "finish", - reason: "创作流程结束", - requiresApproval: false, - }), - ); + try { + const owned = await this.runExclusive(id, s, () => + s.runtime.submitDecision( + createDecision({ + action: "finish", + reason: "创作流程结束", + requiresApproval: false, + }), + ), + ); + if (!owned) return this.toView(id); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push(this.msg("system", formatRuntimeError(detail))); + } return this.toView(id); } @@ -1308,6 +1678,42 @@ export class SessionManager { } } + /** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */ + private maybeSaveOpeningInstance( + s: ManagedSession, + sealedBefore: boolean, + ): void { + if (sealedBefore) return; + if (!s.bookId) return; + const session = s.runtime.getSession(); + if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return; + if (!canEnterPlay(session)) { + s.messages.push( + this.msg( + "system", + "[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。", + ), + ); + return; + } + try { + const meta = this.saveGameSnapshot( + session.id, + INSTANCE_OPENING_SNAPSHOT_LABEL, + "instance", + "选定开场后自动保存", + ); + s.messages.push( + this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`), + ); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + s.messages.push( + this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`), + ); + } + } + private persist(s: ManagedSession): void { if (!s.bookId) return; const book = getBook(s.bookId); @@ -1423,7 +1829,7 @@ export class SessionManager { private buildLiveStreamView(sessionId: string): LiveStreamView | undefined { const worker = this.workerLive.get(sessionId); - if (worker && (worker.thinking || worker.output)) { + if (worker) { return { actor: "worker", actorId: worker.workerId, @@ -1449,6 +1855,42 @@ export class SessionManager { return s; } + private beginRun(s: ManagedSession, sessionId: string): number { + s.runAbort?.abort(); + s.runAbort = new AbortController(); + s.runGen = (s.runGen ?? 0) + 1; + s.runMessageCutoff = s.messages.length; + this.clearAgentThinking(sessionId); + return s.runGen; + } + + private isCurrentRun(s: ManagedSession, gen: number): boolean { + return s.runGen === gen; + } + + /** true = 本轮仍有效;false = 已被停止并重试取代 */ + private async runExclusive( + id: string, + s: ManagedSession, + fn: () => Promise, + ): Promise { + const gen = this.beginRun(s, id); + try { + await runWithAbortSignal(s.runAbort!.signal, fn); + return this.isCurrentRun(s, gen); + } catch (err) { + if (isAbortError(err) && !this.isCurrentRun(s, gen)) { + return false; + } + throw err; + } finally { + if (this.isCurrentRun(s, gen)) { + this.clearAgentThinking(id); + s.runAbort = undefined; + } + } + } + private enrichDisplayMessages( session: RuntimeSession, messages: ChatMessage[], @@ -1595,6 +2037,7 @@ export class SessionManager { private toView(id: string, resumed = false, resumeHint?: string): SessionView { const s = this.require(id); + this.sanitizePlayHitl(s); const session = s.runtime.getSession(); const reason = session.waitingReason; const book = s.bookId ? getBook(s.bookId) : null; @@ -1667,7 +2110,15 @@ export class SessionManager { : "描述你想写什么…", }); } else if (reason?.kind === "input") { - if (reason.questions?.length) { + const playNow = + inferLifecycleStage(session) === "play" || isPlayLayerActive(session.slots); + if (playNow) { + actions.push({ + type: "send_message", + label: "发送", + placeholder: "说你要做什么…", + }); + } else if (reason.questions?.length) { const qs = normalizeQuestions(reason.questions); const hasAssessment = Boolean(reason.message?.trim()); hints.push( @@ -1732,11 +2183,17 @@ export class SessionManager { ); if (proposed) { hints.push( - `确认开始「${proposed.name}」${ - proposed.paramsMissing.length - ? `(请先补齐:${proposed.paramsMissing.join("、")})` - : "" - }`, + proposed.mode === "revise" + ? `回头修改「${proposed.name}」${ + proposed.paramsMissing.length + ? `(请先补齐:${proposed.paramsMissing.join("、")})` + : "(继承既有产物继续改,不是从零再生成)" + }` + : `确认开始「${proposed.name}」${ + proposed.paramsMissing.length + ? `(请先补齐:${proposed.paramsMissing.join("、")})` + : "" + }`, ); actions.push({ type: "approve", @@ -1749,10 +2206,10 @@ export class SessionManager { actions.push({ type: "send_message", label: "说明意见", - placeholder: "不同意可说明要改成什么…", + placeholder: "改排、回头改某步,或说明不要这一步…", }); } else if (reason?.kind === "next_intent") { - hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。"); + hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。"); actions.push({ type: "send_message", label: "继续", @@ -1859,6 +2316,7 @@ export class SessionManager { actions, pipeline: buildPipeline(session), lifecycleStage, + playLayerActive: isPlayLayerActive(session.slots), playReady: canEnterPlay(session), skillCatalog, toolTrace: buildToolTrace(messages), @@ -2246,7 +2704,10 @@ function readProposedNextStep( })) .filter((p) => p.key) : [], + kind: typeof doc.kind === "string" ? doc.kind : undefined, intent: typeof doc.intent === "string" ? doc.intent : undefined, + mode: doc.mode === "revise" ? "revise" : undefined, + revises: typeof doc.revises === "string" ? doc.revises : undefined, }; } catch { return undefined; @@ -2416,6 +2877,7 @@ function buildReviewArtifactView( workerId: art.workerId, summary, body, + outputTags: art.outputTags, sourceMessageId: outputMsg?.id, contextTrace: outputMsg?.contextTrace, workerSet, diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index a94425e..cbb463a 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -22,12 +22,17 @@ import { patchPresetEntries, type PresetEntryPatch, } from "../preset/entries.js"; +import { runPresetProbe } from "../preset/probe.js"; import { deletePreset, getPreset, importAndSavePreset, listPresets, } from "../preset/store.js"; +import { + createLlmForPreset, + hasRealLlmConfig, +} from "../runtime/llm-factory.js"; import { sessionManager } from "./session-manager.js"; async function readBody(req: IncomingMessage): Promise { @@ -226,6 +231,52 @@ export async function handleSettingsApi( return true; } + const presetProbeMatch = pathname.match(/^\/api\/presets\/([^/]+)\/probe$/); + if (presetProbeMatch && req.method === "POST") { + const id = decodeURIComponent(presetProbeMatch[1]); + const preset = getPreset(id); + if (!preset) { + json(res, 404, { error: "预设不存在" }); + return true; + } + const body = JSON.parse(await readBody(req)) as { + message?: string; + loreBefore?: string; + history?: string; + loreAfter?: string; + postTurn?: string; + complete?: boolean; + }; + try { + const wantComplete = body.complete !== false; + const canComplete = wantComplete && hasRealLlmConfig(); + const llm = canComplete ? createLlmForPreset(preset) : null; + const result = await runPresetProbe({ + preset, + input: { + message: body.message ?? "", + loreBefore: body.loreBefore, + history: body.history, + loreAfter: body.loreAfter, + postTurn: body.postTurn, + }, + complete: llm + ? (messages) => + llm.complete(messages, { caller: "preset-probe" }) + : undefined, + }); + if (!llm && wantComplete) { + result.skipReason = "未配置 API Key,只返回拼装后的上下文"; + } + json(res, 200, result); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : String(err), + }); + } + return true; + } + const presetEntriesMatch = pathname.match( /^\/api\/presets\/([^/]+)\/entries$/, ); diff --git a/src/server/skill-catalog.ts b/src/server/skill-catalog.ts index ad54c5b..abd5a52 100644 --- a/src/server/skill-catalog.ts +++ b/src/server/skill-catalog.ts @@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog( id: "declare-ready", stage: "design", label: "实例就绪", - purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。", + purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。", status: hasAcceptedWorkerSet(session) ? "done" : "pending", }); diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 7b9b506..3bfdbe0 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -444,9 +444,11 @@ const server = createServer(async (req, res) => { const body = JSON.parse(await readBody(req)) as { action?: string; stepParams?: Record; + openingIndex?: number; }; let view; - switch (body.action) { + try { + switch (body.action) { case "approve": view = await sessionManager.approve( sessionId, @@ -459,7 +461,12 @@ const server = createServer(async (req, res) => { view = await sessionManager.confirmIntake(sessionId); break; case "accept": - view = await sessionManager.accept(sessionId); + view = await sessionManager.accept( + sessionId, + typeof body.openingIndex === "number" + ? { openingIndex: body.openingIndex } + : undefined, + ); break; case "skip_questions": view = await sessionManager.skipQuestions(sessionId); @@ -473,10 +480,19 @@ const server = createServer(async (req, res) => { case "finish": view = await sessionManager.finish(sessionId); break; + case "retry_run": + view = await sessionManager.abortAndRetry(sessionId); + break; default: json(res, 400, { error: "未知 action" }); return; } + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "操作失败", + }); + return; + } json(res, 200, view); return; } diff --git a/src/skills/context-fragment.ts b/src/skills/context-fragment.ts index 7341831..8118e9f 100644 --- a/src/skills/context-fragment.ts +++ b/src/skills/context-fragment.ts @@ -163,12 +163,22 @@ export function projectFragmentContent( const frag = parseContextFragment(raw); const mode = projection.trim().toLowerCase(); if (!frag) { - if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}…`; + if ((mode === "summary" || mode === "brief" || mode === "index") && raw.length > 800) { + return `${raw.slice(0, 800)}…`; + } return raw; } if (mode === "summary" || mode === "brief") { return frag.brief || raw; } + if (mode === "index") { + const lines: string[] = []; + if (frag.技能) lines.push(`技能:${frag.技能}`); + if (frag.brief) lines.push(frag.brief); + if (frag.mount.length) lines.push(`挂载:${frag.mount.join("、")}`); + if (frag.稳变) lines.push(`稳变:${frag.稳变}`); + return lines.join("\n") || frag.brief || raw; + } if (mode === "fields") { if (frag.正文 && typeof frag.正文 === "object" && !Array.isArray(frag.正文)) { const keys = Object.keys(frag.正文 as object); @@ -237,9 +247,11 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView { const probe = rawDoc?.追问; if (probe && typeof probe === "object" && !Array.isArray(probe)) { const row = probe as Record; - const lines: string[] = []; - if (typeof row.导语 === "string" && row.导语.trim()) lines.push(row.导语.trim()); const qs = Array.isArray(row.题目) ? row.题目 : []; + const lines: string[] = []; + if (qs.length && typeof row.导语 === "string" && row.导语.trim()) { + lines.push(row.导语.trim()); + } qs.forEach((q, i) => { if (!q || typeof q !== "object") return; const r = q as Record; @@ -282,7 +294,9 @@ export function extractFragmentAskSidecar(raw: unknown): { const looksFragment = row.schema === CONTEXT_FRAGMENT_SCHEMA || - (typeof row.brief === "string" && ("正文" in row || "body" in row)); + typeof row.技能 === "string" || + (typeof row.brief === "string" && + ("正文" in row || "body" in row || "追问" in row)); if (!looksFragment) return { questions: [], assessment: "" }; const assessmentParts: string[] = []; @@ -358,7 +372,12 @@ export function extractFragmentAskSidecar(raw: unknown): { } const questions = normalizeQuestions(rawQs); - const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n"); + const assessment = [ + questions.length ? lead : "", + ...assessmentParts, + ] + .filter(Boolean) + .join("\n"); return { questions, assessment }; } diff --git a/src/skills/context-order.ts b/src/skills/context-order.ts index aa19e75..31c2e22 100644 --- a/src/skills/context-order.ts +++ b/src/skills/context-order.ts @@ -6,9 +6,14 @@ import type { ContextSegmentDef } from "./context-segments.js"; import { extractJsonObjectText } from "./worker-set-parse.js"; import { + DEFAULT_ON_DEMAND_REFS, + DEFAULT_PLAY_SLOT_REFS, + defaultPlaySlots, parsePlaySlots, playSlotIdForRef, + PLAY_SLOT_META, PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY, + resolvePlayAgentId, type PlaySlotsConfig, } from "./play-slots.js"; import { @@ -87,20 +92,53 @@ function parseInsert(raw: unknown): ContextOrderInsert | undefined { function parseSlot(raw: unknown): ContextOrderSlot | undefined { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; const row = raw as Record; - const ref = asString(row.ref) ?? asString(row.id); - if (!ref) return undefined; + const rawRef = asString(row.ref) ?? asString(row.id); + if (!rawRef) return undefined; + const slotId = resolvePlayAgentId(rawRef); + const ref = slotId + ? slotId === "chance" + ? DEFAULT_ON_DEMAND_REFS.chance + : DEFAULT_PLAY_SLOT_REFS[slotId] + : rawRef; const insertsRaw = Array.isArray(row.inserts) ? row.inserts : []; const inserts = insertsRaw .map(parseInsert) .filter((x): x is ContextOrderInsert => Boolean(x)) .sort((a, b) => a.order - b.order); + const label = + asString(row.label) ?? + asString(row.name) ?? + (slotId && slotId !== "chance" ? PLAY_SLOT_META[slotId].label : undefined); return { ref, - label: asString(row.label) ?? asString(row.name), + label, inserts, }; } +function agentEnabledFlag(row: Record): boolean { + if (typeof row.enabled === "boolean") return row.enabled; + if (row.enabled === "false" || row.enabled === 0) return false; + return true; +} + +function playSlotsFromAgents( + rows: Record[], +): PlaySlotsConfig { + const base = defaultPlaySlots(); + for (const row of rows) { + const rawId = asString(row.id) ?? asString(row.ref) ?? asString(row.label); + if (!rawId) continue; + const slotId = resolvePlayAgentId(rawId); + if (!slotId) continue; + const on = agentEnabledFlag(row); + if (slotId === "chance") base.chance = on; + else if (slotId === "auditor") base.auditor = on; + else base[slotId] = on; + } + return base; +} + export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined { let row: Record | null = null; if (typeof raw === "string") { @@ -118,16 +156,32 @@ export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined { } if (!row) return undefined; - const slotsRaw = Array.isArray(row.slots) ? row.slots : []; - const slots = slotsRaw + const agentsRaw = Array.isArray(row.agents) ? row.agents : []; + const agentRows = agentsRaw.filter( + (a): a is Record => + Boolean(a) && typeof a === "object" && !Array.isArray(a), + ); + const fromAgents = agentRows + .filter(agentEnabledFlag) .map(parseSlot) .filter((x): x is ContextOrderSlot => Boolean(x)); + + const slotsRaw = Array.isArray(row.slots) ? row.slots : []; + const fromSlots = slotsRaw + .map(parseSlot) + .filter((x): x is ContextOrderSlot => Boolean(x)); + + const slots = fromAgents.length ? fromAgents : fromSlots; if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined; + const play_slots = fromAgents.length + ? playSlotsFromAgents(agentRows) + : parsePlaySlots(row.play_slots ?? row.playSlots); + return { schema: CONTEXT_ORDER_SCHEMA, brief: asString(row.brief), - play_slots: parsePlaySlots(row.play_slots ?? row.playSlots), + play_slots, slots, }; } diff --git a/src/skills/context-segments.ts b/src/skills/context-segments.ts index 47ee9b6..4a73d82 100644 --- a/src/skills/context-segments.ts +++ b/src/skills/context-segments.ts @@ -89,17 +89,40 @@ function readTagContent( return items[items.length - 1]?.content?.trim() ?? ""; } +/** 单段投影正文(不含 label)。供世界书 marker 填洞复用。 */ +export function renderContextSegmentBody( + segment: ContextSegmentDef, + inputs: Record, + blackboard: Blackboard, + inputMerge: BlackboardInputMerge, + skipExact?: Set, + skipTags?: Set, +): string { + return formatSegmentBody( + segment, + inputs, + blackboard, + inputMerge, + skipExact, + skipTags, + ); +} + function formatSegmentBody( segment: ContextSegmentDef, inputs: Record, blackboard: Blackboard, inputMerge: BlackboardInputMerge, + skipExact?: Set, + skipTags?: Set, ): string { if (segment.inline?.trim()) { return applyProjection(segment.inline.trim(), segment.projection); } const parts: string[] = []; + const seen = new Set(); for (const tag of segment.tags) { + if (skipTags?.has(tag)) continue; let content: string; if (isDialogueHistoryRef(tag)) { content = resolveDialogueHistory(inputs, blackboard, inputMerge); @@ -115,8 +138,22 @@ function formatSegmentBody( content = applyPolicy(content, segment.policy); content = applyProjection(content, segment.projection); } - if (!content.trim()) continue; - parts.push(content.trim()); + const trimmed = content.trim(); + if (!trimmed) continue; + if (skipExact?.has(trimmed)) continue; + if (seen.has(trimmed)) continue; + if ( + parts.some( + (p) => + p === trimmed || + p.includes(`\n\n${trimmed}`) || + p.startsWith(`${trimmed}\n\n`), + ) + ) { + continue; + } + seen.add(trimmed); + parts.push(trimmed); } return parts.join("\n\n"); } @@ -141,6 +178,61 @@ function applyProjection(content: string, projection?: string): string { return projectFragmentContent(content, projection); } +const USER_REQUIREMENT_TAGS = new Set([ + "用户.需求", + "book.brief", + "用户.最新输入", + "用户.worker答复", + "用户.修订说明", +]); + +const CREATION_REVIEW_WORKERS = new Set([ + "design-step", + "design-flow", + "opening-generator", +]); + +function alreadyCovered(parts: string[], next: string): boolean { + const trimmed = next.trim(); + if (!trimmed) return true; + return parts.some( + (p) => + p === trimmed || + p.includes(`\n\n${trimmed}`) || + p.startsWith(`${trimmed}\n\n`), + ); +} + +function pushUnique(parts: string[], next: string): void { + const trimmed = next.trim(); + if (!trimmed || alreadyCovered(parts, trimmed)) return; + parts.push(trimmed); +} + +/** 创作步:用户要求按时间排;冲突只看较新,旧条未点名推翻则仍有效。 */ +function collectUserRequirementBlock(params: { + inputs: Record; + blackboard: Blackboard; + inputMerge: BlackboardInputMerge; +}): string { + const { inputs, blackboard, inputMerge } = params; + const ordered = [ + readTagContent("用户.需求", inputs, blackboard, inputMerge), + readTagContent("book.brief", inputs, blackboard, inputMerge), + readTagContent("用户.worker答复", inputs, blackboard, inputMerge), + readTagContent("用户.最新输入", inputs, blackboard, inputMerge), + readTagContent("用户.修订说明", inputs, blackboard, inputMerge), + ]; + const parts: string[] = []; + for (const item of ordered) pushUnique(parts, item); + if (!parts.length) return ""; + return [ + "## 【用户要求】按先后排列。冲突时以较新的为准;未被较新要求改写的旧条仍有效。", + "", + ...parts, + ].join("\n\n"); +} + /** * 按 contextSegments 拼装 user 侧上下文。 * 无 segments 时回退为 JSON inputs(兼容旧 skill)。 @@ -178,10 +270,49 @@ export function assembleWorkerContext(params: { ); } + const revisionNote = readTagContent( + "用户.修订说明", + params.inputs, + params.blackboard, + inputMerge, + ).trim(); + const inheritExisting = segments.some((s) => s.id === "inherit-existing"); + const splitUserReq = CREATION_REVIEW_WORKERS.has(params.workerId); + + const draftBlocks: string[] = []; + for (const tag of params.outputTags) { + const alreadyInSegments = segments.some((s) => s.tags.includes(tag)); + if (alreadyInSegments) continue; + const draft = readTagContent( + tag, + params.inputs, + params.blackboard, + inputMerge, + ).trim(); + if (!draft) continue; + draftBlocks.push(`### \`${tag}\`\n\n${draft}`); + } + + // 只有本步真有底稿(或回头修改)才算修订;上一步残留的修订说明仍作为用户要求保留 + const inheritMode = inheritExisting || (Boolean(revisionNote) && draftBlocks.length > 0); + + const skipTags = new Set(); + if (splitUserReq) { + for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag); + } + const blocks: string[] = []; const render = (seg: ContextSegmentDef) => { - const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge); + if (inheritMode && seg.id === "module-opening") return; + const body = formatSegmentBody( + seg, + params.inputs, + params.blackboard, + inputMerge, + undefined, + skipTags, + ); if (!body) return; if (seg.label) { blocks.push(`${seg.label}\n\n${body}`); @@ -193,6 +324,15 @@ export function assembleWorkerContext(params: { // 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排 for (const seg of segments) render(seg); + if (splitUserReq) { + const reqBlock = collectUserRequirementBlock({ + inputs: params.inputs, + blackboard: params.blackboard, + inputMerge, + }); + if (reqBlock) blocks.push(reqBlock); + } + // 定稿摘要:若未在 segments 中声明,仍附在末尾 const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim(); const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG)); @@ -200,39 +340,17 @@ export function assembleWorkerContext(params: { blocks.push(`## 上下文.定稿摘要\n\n${brief}`); } - // 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写 - const revisionNote = readTagContent( - "用户.修订说明", - params.inputs, - params.blackboard, - inputMerge, - ).trim(); - const draftBlocks: string[] = []; - if (revisionNote) { - for (const tag of params.outputTags) { - const alreadyInSegments = segments.some((s) => s.tags.includes(tag)); - if (alreadyInSegments) continue; - const draft = readTagContent( - tag, - params.inputs, - params.blackboard, - inputMerge, - ).trim(); - if (!draft) continue; - draftBlocks.push(`### \`${tag}\`\n\n${draft}`); - } - if (draftBlocks.length) { - blocks.push( - [ - "## 【待改底稿】", - "", - "下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。", - "节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。", - "", - ...draftBlocks, - ].join("\n"), - ); - } + if (inheritMode && draftBlocks.length) { + blocks.push( + [ + "## 【待改底稿】", + "", + "下列为**当前已有产物**(只保留这一份最新稿)。在其上落实【用户要求】里较新的条目;与旧要求冲突时以较新的为准,未点名改写的旧条仍须保留在产物里。", + "节点方法(task / principles / probe / output)仍须遵守。禁止无视较新要求原样交回。", + "", + ...draftBlocks, + ].join("\n"), + ); } const taskLines = [ @@ -243,10 +361,12 @@ export function assembleWorkerContext(params: { `- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`, "", ]; - if (revisionNote) { + if (inheritMode) { taskLines.push( - "**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。", - "不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。", + inheritExisting + ? "**回头修改**:接着上方【既有产物 · 继承修改】(以及【待改底稿】若有)继续改,写回同一产物。" + : "**修订模式**:以【待改底稿】为底,落实【用户要求】;冲突时以较新的为准。", + "不要从零另起一份。标为「只读 / 已定稿」的分区仍不可改。", ); } else { taskLines.push( diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index 59511b5..47b6113 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -53,9 +53,41 @@ const DEFAULT_SKILLS_ROOT = path.resolve( "../../skills", ); -/** 步骤调用参数(编排期钉死;执行期只读) */ +/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕:编排器规划的产物内容(可空),执行时注入为【规划产物】。 */ export type CreationFlowStepParams = Record; +/** + * 节点特性。缺省 = 普通执行步。后续会加更多 kind。 + * prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。 + */ +export const MODULE_NODE_KINDS = ["prior-artifact"] as const; +export type ModuleNodeKind = (typeof MODULE_NODE_KINDS)[number]; + +export const MODULE_NODE_KIND_FLAGS: Record = { + "prior-artifact": "〔先验产物〕", +}; + +export function parseModuleNodeKind(raw: unknown): ModuleNodeKind | undefined { + if (typeof raw !== "string") return undefined; + const key = raw.trim(); + return (MODULE_NODE_KINDS as readonly string[]).includes(key) + ? (key as ModuleNodeKind) + : undefined; +} + +export function isPriorArtifactModule( + module: { kind?: ModuleNodeKind } | null | undefined, +): boolean { + return module?.kind === "prior-artifact"; +} + +/** + * 本步怎么跑: + * - fresh(缺省)= 从零产出。〔可反复〕再编入新 id 必须用这个——彻底新建,不改旧条。 + * - revise = 回头修改:继承 `revises` 所指那步的既有产物继续改。 + */ +export type CreationFlowStepMode = "fresh" | "revise"; + export type CreationFlowStep = { /** * 本局步骤唯一 id(验收与 depends_on 用这个)。 @@ -67,10 +99,15 @@ export type CreationFlowStep = { /** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */ depends_on: string[]; /** - * 本步调用参数。有「编排参数」声明的能力必须在进 design-step 前钉齐必填项。 - * 例:生成规则 → target;具体实例 → rule_id。 + * 本步调用参数。 + * 普通节点:有必填声明时须在进执行前钉齐。 + * 〔先验产物〕:编排器规划的产物内容(可空);执行注入【规划产物】,步内可修订。 */ params?: CreationFlowStepParams; + /** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */ + mode?: CreationFlowStepMode; + /** mode=revise 时:被改的既有步骤 id(须更前、同 name) */ + revises?: string; }; export type CreationFlowStatus = "open" | "closed"; @@ -111,14 +148,29 @@ export type ModuleCatalogEntry = { * 程序不硬拦;给编排与校验提示。 */ repeatable?: boolean; + /** + * 可选:创作终节点。选定后程序收口并保存,其后不要再追加步骤。 + */ + closer?: boolean; + /** + * 可选:程序步。不抛默认问题、不经「同意并开始」,提案后直接执行; + * 产物仍走验收。适合投影排序这类只排序、几乎不问用户的收成步。 + */ + auto?: boolean; + /** + * 可选:节点特性。缺省为普通执行步。 + * prior-artifact = 先验产物,须先定写什么;params 是规划产物而非拦执行的必填项。 + */ + kind?: ModuleNodeKind; /** * 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块; * catalog 写了则作覆盖。程序发出,不经 LLM。 */ opening?: string; /** - * 可选:编排期步骤参数声明。 - * 有 required 项时,steps[].params 必须在进执行前钉齐;勿把选型推迟到 design-step。 + * 可选:步骤参数声明。 + * 普通节点:required 须在进执行前钉齐。 + * 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。 */ params?: ModuleParamSpec[]; /** 来自 prompt.md ```meta:何时该选用(编排选型) */ @@ -191,15 +243,21 @@ export type CreationFlowUserView = { id: string; name: string; depends_on: string[]; - /** 同能力第几次(>1 时 UI 可标「再来」) */ + /** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */ occurrence?: number; + /** fresh=从零新建;revise=回头修改 */ + mode?: CreationFlowStepMode; + /** 回头修改时:被改的既有步骤 id */ + revises?: string; /** 目录里的短声明(有则展示) */ declaration?: string; repeatable?: boolean; - /** 本步调用参数(编排期钉死) */ + /** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */ params?: CreationFlowStepParams; - /** 参数缺必填项时的提示(给人看) */ + /** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */ paramsMissing?: string[]; + /** 节点特性(如先验产物) */ + kind?: ModuleNodeKind; /** 已执行 / 将要执行 / 未执行 */ runState: FlowStepRunState; }>; @@ -241,11 +299,12 @@ export function normalizeStepParams( return Object.keys(out).length > 0 ? out : undefined; } -/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序) */ +/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序)。〔先验产物〕不拦执行,恒为 []。 */ export function missingRequiredStepParams( step: Pick, module: ModuleCatalogEntry | null | undefined, ): string[] { + if (isPriorArtifactModule(module)) return []; const specs = module?.params ?? []; if (specs.length === 0) return []; const params = step.params ?? {}; @@ -278,14 +337,120 @@ export function formatStepParamsForPrompt( .join("\n"); } -/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#2、name#3… */ +const PRIOR_ARTIFACT_CONTEXT_TITLE = + "## 【规划产物】编排器预先为本步规划的产物内容(先验;缺则步内钉;步内可修订)"; + +/** 〔先验产物〕注入块:规划可空,身份写在片段自身。 */ +export function formatPriorArtifactContext( + params: CreationFlowStepParams | null | undefined, +): string { + const body = formatStepParamsForPrompt(params); + const note = + !params || Object.keys(params).length === 0 + ? "编排器尚未规划本步具体写什么。先在本步与用户钉「写什么」,再填产物。" + : "以上为先验规划,不是锁死合同。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。"; + return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}`; +} + +const REVISE_MODE_ALIASES = new Set([ + "revise", + "amend", + "edit", + "修改", + "回头修改", + "继承修改", +]); +const FRESH_MODE_ALIASES = new Set([ + "fresh", + "new", + "create", + "新建", + "再来", + "再来一次", +]); + +/** 解析 DAG 步骤的 mode;无法识别则视为缺省(fresh) */ +export function parseCreationFlowStepMode(raw: unknown): CreationFlowStepMode | undefined { + if (typeof raw !== "string") return undefined; + const key = raw.trim().toLowerCase(); + if (!key) return undefined; + if (REVISE_MODE_ALIASES.has(key) || REVISE_MODE_ALIASES.has(raw.trim())) { + return "revise"; + } + if (FRESH_MODE_ALIASES.has(key) || FRESH_MODE_ALIASES.has(raw.trim())) { + return "fresh"; + } + return undefined; +} + +export function isReviseStep( + step: Pick | null | undefined, +): boolean { + return step?.mode === "revise"; +} + +function parseStepRevisesRef(raw: Record): string | undefined { + for (const key of ["revises", "revise_of", "revises_id"] as const) { + const v = raw[key]; + if (typeof v === "string" && v.trim()) return v.trim(); + } + return undefined; +} + +type DraftCreationFlowStep = { + id?: string; + name: string; + depends_on: string[]; + params?: CreationFlowStepParams; + mode?: CreationFlowStepMode; + revises?: string; +}; + +function withStepModeFields( + step: DraftCreationFlowStep, +): Pick { + return { + ...(step.mode === "revise" ? { mode: "revise" as const } : {}), + ...(step.revises ? { revises: step.revises } : {}), + }; +} + +/** + * 回头修改步:补 revises(最近一条同名更前步),并继承其 params(本步已写的优先)。 + */ +export function normalizeReviseSteps( + steps: CreationFlowStep[], +): CreationFlowStep[] { + return steps.map((step, i) => { + if (step.mode !== "revise") return step; + let revises = step.revises?.trim() || ""; + if (!revises) { + for (let j = i - 1; j >= 0; j--) { + if (steps[j]?.name === step.name) { + revises = steps[j]!.id; + break; + } + } + } + const origin = revises + ? steps.find((s) => s.id === revises || s.name === revises) + : undefined; + const inherited = origin?.params; + const params = + inherited && Object.keys(inherited).length + ? { ...inherited, ...(step.params ?? {}) } + : step.params; + return { + ...step, + ...(revises ? { revises } : {}), + ...(params && Object.keys(params).length ? { params } : {}), + }; + }); +} + +/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#n;回头修改 → name·改 */ export function ensureCreationFlowStepIds( - steps: Array<{ - id?: string; - name: string; - depends_on: string[]; - params?: CreationFlowStepParams; - }>, + steps: Array, ): CreationFlowStep[] { const used = new Set(); const nameCount = new Map(); @@ -295,10 +460,20 @@ export function ensureCreationFlowStepIds( const name = raw.name.trim(); const n = (nameCount.get(name) ?? 0) + 1; nameCount.set(name, n); + const revise = raw.mode === "revise"; let id = typeof raw.id === "string" ? raw.id.trim() : ""; if (!id) { - id = n === 1 ? name : `${name}#${n}`; + if (revise) { + id = n === 1 ? `${name}·改` : `${name}·改`; + if (used.has(id)) { + let i = 2; + while (used.has(`${name}·改#${i}`)) i++; + id = `${name}·改#${i}`; + } + } else { + id = n === 1 ? name : `${name}#${n}`; + } } if (used.has(id)) { let i = 2; @@ -311,9 +486,10 @@ export function ensureCreationFlowStepIds( name, depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean), ...(raw.params ? { params: raw.params } : {}), + ...withStepModeFields(raw), }); } - return out; + return normalizeReviseSteps(out); } /** 按 id 或(唯一)name 解析步骤引用 */ @@ -378,12 +554,7 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow const stepsRaw = row.steps; if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null; - const drafted: Array<{ - id?: string; - name: string; - depends_on: string[]; - params?: CreationFlowStepParams; - }> = []; + const drafted: DraftCreationFlowStep[] = []; for (const item of stepsRaw) { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const s = item as Record; @@ -395,7 +566,17 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow ? depsRaw.map((d) => String(d).trim()).filter(Boolean) : []; const params = normalizeStepParams(s.params); - drafted.push({ id, name, depends_on, ...(params ? { params } : {}) }); + const mode = + parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent); + const revises = parseStepRevisesRef(s); + drafted.push({ + id, + name, + depends_on, + ...(params ? { params } : {}), + ...(mode ? { mode } : {}), + ...(revises ? { revises } : {}), + }); } const steps = ensureCreationFlowStepIds(drafted); @@ -440,6 +621,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null { ? m.opening.trim() : undefined; const repeatable = m.repeatable === true; + const closer = m.closer === true; + const auto = m.auto === true; + const kind = parseModuleNodeKind(m.kind); const params = parseModuleParamSpecs(m.params); modules.push({ id, @@ -447,6 +631,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null { declaration, artifact, ...(repeatable ? { repeatable: true } : {}), + ...(closer ? { closer: true } : {}), + ...(auto ? { auto: true } : {}), + ...(kind ? { kind } : {}), ...(opening ? { opening } : {}), ...(params ? { params } : {}), }); @@ -702,7 +889,9 @@ export function mergeCreationFlowPreservingAccepted(params: { const changed = current.name !== frozen.name || JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) || - JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null); + JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) || + (current.mode ?? "fresh") !== (frozen.mode ?? "fresh") || + (current.revises ?? "") !== (frozen.revises ?? ""); if (changed) { steps[at] = frozen; restored.push(frozen.id); @@ -795,29 +984,49 @@ export async function loadModuleCatalog( /** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { const lines = catalog.modules.map((m) => { - const flags = m.repeatable ? "〔可反复〕" : ""; + const flags = [ + m.repeatable ? "〔可反复〕" : "", + m.closer ? "〔收口〕" : "", + m.auto ? "〔程序步〕" : "", + m.kind ? MODULE_NODE_KIND_FLAGS[m.kind] : "", + ] + .filter(Boolean) + .join(""); const parts: string[] = [`- ${m.name}${flags}:${m.declaration}`]; if (m.when) parts.push(` 何时用:${indentMultiline(m.when, " ")}`); if (m.when_not) parts.push(` 何时不用:${indentMultiline(m.when_not, " ")}`); if (m.boundary) parts.push(` 边界:${indentMultiline(m.boundary, " ")}`); if (m.params && m.params.length > 0) { - parts.push( - ` 编排参数:${m.params - .map((p) => { - const req = p.required ? "必填" : "可选"; - const hint = p.hint ? `,${p.hint}` : ""; - return `${p.key}(${p.label},${req}${hint})`; - }) - .join(";")}`, - ); + if (isPriorArtifactModule(m)) { + parts.push( + ` 规划产物字段(可提前写入 params;空则步内钉):${m.params + .map((p) => { + const hint = p.hint ? `,${p.hint}` : ""; + return `${p.key}(${p.label}${hint})`; + }) + .join(";")}`, + ); + } else { + parts.push( + ` 编排参数:${m.params + .map((p) => { + const req = p.required ? "必填" : "可选"; + const hint = p.hint ? `,${p.hint}` : ""; + return `${p.key}(${p.label},${req}${hint})`; + }) + .join(";")}`, + ); + } } return parts.join("\n"); }); return [ "【能力 · 可选工序】", - "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。", - "选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。", - "生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。", + "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:选定后结束创作并保存,排在细化终稿之后,其后不要再追加步骤;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。", + "标〔先验产物〕的步骤(如生成规则、具体实例):须先定「写什么」。编排器可以提前规划多条并写入 params——这是正确的;执行时程序注入【规划产物】。规划可空,「生成什么」在步内钉。禁止为钉对象而 askUser 拦在确认开干前。规划或修订对象时同步改 rule_id(英文 kebab-case)与步骤 id(如 生成规则·{对象}),禁止只改中文沿用旧后台 id。", + "其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。", + "选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。", + "两种「再来一次」必须分开:〔可反复〕再编入新 id(mode 缺省/fresh)= 彻底新建一条;回头修改已完成节点 = mode=revise + revises=原步 id,继承旧产物继续改。禁止把回头修改写成又一条 fresh。", "不要把能力执行全文塞进本步;执行由 design-step 注入。", lines.join("\n"), ].join("\n"); @@ -1153,8 +1362,8 @@ export function formatFlowProgressForAgent(params: { repeatable: boolean, ) => { const flag = repeatable - ? "〔可反复:可再追加不同 id〕" - : "〔不可反复:禁止再排一次执行〕"; + ? "〔可反复:mode=fresh 再追加 = 彻底新建;要改旧条用 mode=revise〕" + : "〔不可反复:禁止再排新建;要改已完成节点用 mode=revise 回头修改〕"; const idBit = id ? `(id: ${id})` : ""; doneLines.push(`- ${name}${idBit} · ${why} ${flag}`); doneNames.add(name); @@ -1196,9 +1405,12 @@ export function formatFlowProgressForAgent(params: { const draftLines: string[] = []; if (flow) { for (const step of flow.steps) { - if (doneIds.has(step.id) || doneNames.has(step.name)) continue; + if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue; + const reviseBit = isReviseStep(step) + ? ` · 回头修改${step.revises ? `(revises: ${step.revises})` : ""}` + : ""; draftLines.push( - `- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`, + `- ${step.name}(id: ${step.id})${reviseBit} · 已在草案,保留原 id;不要当作新规划再写一遍`, ); } } @@ -1210,9 +1422,9 @@ export function formatFlowProgressForAgent(params: { const lines = [ "【流程进度】(程序钉死,必须遵守)", "你的任务是追加缺口,不是从头规划。", - "已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。", - "草案里已有的步骤:原样保留,不要重排一遍开局。", - "只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。", + "已完成的非反复技能:禁止再排一次「新建」(mode=fresh)。用户要改已完成节点 → 追加新 id,mode=revise,revises=原步 id;程序会继承既有产物。", + "〔可反复〕再编入新 id 且 mode 缺省/fresh = 彻底新建一条(新对象/新批次),不是改旧的。要改已有那一条 → 同样用 mode=revise。", + "草案里已有的步骤:原样保留,不要重排一遍开局。回头修改步插在最近已验收步之后、尚未执行的步之前。", ]; if (doneLines.length) { lines.push("", "已完成:", ...doneLines); @@ -1258,10 +1470,7 @@ export function validateCreationFlow( const allowed = catalog ? new Set(catalog.modules.map((m) => m.name)) : null; - const nameCount = new Map(); - for (const step of flow.steps) { - nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1); - } + const reportedFreshDup = new Set(); for (let i = 0; i < flow.steps.length; i++) { const step = flow.steps[i]!; @@ -1275,14 +1484,44 @@ export function validateCreationFlow( } const mod = catalog?.modules.find((m) => m.name === step.name); - if ( + if (isReviseStep(step)) { + const ref = step.revises?.trim() ?? ""; + if (!ref) { + errors.push( + `「${step.id}」是回头修改,但未标明 revises(应指向更前的同名步骤 id)`, + ); + } else { + const origin = findStepByRef(flow, ref); + if (!origin) { + errors.push( + `「${step.id}」回头修改「${ref}」,但流程中没有该步骤`, + ); + } else if (origin.id === step.id) { + errors.push(`「${step.id}」不能回头修改自己`); + } else if (origin.name !== step.name) { + errors.push( + `「${step.id}」回头修改「${origin.id}」,但能力名不同(${step.name} ≠ ${origin.name})`, + ); + } else { + const originIndex = flow.steps.findIndex((s) => s.id === origin.id); + if (originIndex >= i) { + errors.push( + `「${step.id}」回头修改「${origin.id}」,但「${origin.id}」未排在其前面`, + ); + } + } + } + } else if ( catalog && - (nameCount.get(step.name) ?? 0) > 1 && mod && - mod.repeatable !== true + mod.repeatable !== true && + !reportedFreshDup.has(step.name) && + flow.steps.filter((s) => s.name === step.name && !isReviseStep(s)).length > + 1 ) { + reportedFreshDup.add(step.name); errors.push( - `「${step.name}」出现多次,但目录未标 repeatable(仅可反复能力可同名多次)`, + `「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能要改旧稿请用 mode=revise)`, ); } @@ -1306,7 +1545,7 @@ export function validateCreationFlow( return { ok: errors.length === 0, errors }; } -/** 进 design-step 前:本步必填编排参数是否已齐 */ +/** 进 design-step 前:本步必填编排参数是否已齐。〔先验产物〕不拦。 */ export function validateStepReadyToRun( step: CreationFlowStep, catalog: ModuleCatalog | null, @@ -1362,14 +1601,19 @@ export function formatCreationFlowForUser( const decl = new Map( (catalog?.modules ?? []).map((m) => [m.name, m] as const), ); - const seenName = new Map(); + const seenFreshName = new Map(); const next = nextPendingStep(flow, acceptedStepIds); return { brief: flow.brief, status: flow.status, steps: flow.steps.map((s, i) => { - const n = (seenName.get(s.name) ?? 0) + 1; - seenName.set(s.name, n); + const revise = isReviseStep(s); + let occurrence: number | undefined; + if (!revise) { + const n = (seenFreshName.get(s.name) ?? 0) + 1; + seenFreshName.set(s.name, n); + occurrence = n; + } const mod = decl.get(s.name); const paramsMissing = missingRequiredStepParams(s, mod); const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds) @@ -1382,9 +1626,12 @@ export function formatCreationFlowForUser( id: s.id, name: s.name, depends_on: s.depends_on, - occurrence: n, + occurrence, + ...(revise ? { mode: "revise" as const } : {}), + ...(s.revises ? { revises: s.revises } : {}), declaration: mod?.declaration, repeatable: mod?.repeatable, + ...(mod?.kind ? { kind: mod.kind } : {}), runState, ...(s.params ? { params: s.params } : {}), ...(paramsMissing.length > 0 ? { paramsMissing } : {}), @@ -1443,6 +1690,7 @@ export function nextPendingStep( acceptedStepIds: readonly string[], ): CreationFlowStep | null { if (!flow?.steps.length) return null; + const ready: CreationFlowStep[] = []; for (const step of flow.steps) { if (isStepAccepted(step, acceptedStepIds)) continue; const depsOk = step.depends_on.every((dep) => { @@ -1450,9 +1698,9 @@ export function nextPendingStep( if (!depStep) return false; return isStepAccepted(depStep, acceptedStepIds); }); - if (depsOk) return step; + if (depsOk) ready.push(step); } - return null; + return ready.find((s) => isReviseStep(s)) ?? ready[0] ?? null; } /** 当前已列出的步骤是否都已验收(不管 status) */ @@ -1532,8 +1780,10 @@ export type DesignStepBinding = { module: ModuleCatalogEntry; depTags: string[]; modulePrompt: string; - /** 程序开场白;无则本步直接调 LLM */ + /** 程序开场白;无则本步直接调 LLM。回头修改不抛 opening。 */ opening: string | null; + /** 回头修改时注入的既有产物 tag */ + inheritTag: string | null; }; /** @@ -1570,7 +1820,9 @@ export async function resolveDesignStepBinding(params: { `# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`; const opening = - module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null; + isReviseStep(step) || module.auto + ? null + : module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null; return { step, @@ -1578,5 +1830,6 @@ export async function resolveDesignStepBinding(params: { depTags: dependencyArtifactTags(step, catalog, flow), modulePrompt: formatModulePromptForLlm(modulePromptRaw), opening, + inheritTag: isReviseStep(step) ? module.artifact : null, }; } diff --git a/src/skills/declared-worker.ts b/src/skills/declared-worker.ts index fb831cb..4c8c061 100644 --- a/src/skills/declared-worker.ts +++ b/src/skills/declared-worker.ts @@ -31,6 +31,7 @@ import { slotOrderForRef, } from "./context-order.js"; import type { ContextSegmentDef } from "./context-segments.js"; +import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js"; type WorkerTemplateDoc = { id?: string; @@ -44,8 +45,9 @@ type WorkerTemplateDoc = { /** * 解析本次 worker 的验收模式(创作 / run 共用入口)。 * - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed + * - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收) * - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation - * - 未声明 acceptance:design 生命周期默认确认;play 缺省按 review(稳妥) + * - 未声明 acceptance:创作默认确认 */ export function resolveAcceptanceModeForWorker(params: { session: RuntimeSession; @@ -56,6 +58,9 @@ export function resolveAcceptanceModeForWorker(params: { if (isDesignDiskWorker(workerId) || workerId === "opening-generator") { return "user_confirmed"; } + if (inferLifecycleStage(params.session) === "play") { + return "no_confirmation"; + } const decl = buildInstanceWorkerDeclaration( params.session, @@ -66,7 +71,6 @@ export function resolveAcceptanceModeForWorker(params: { if (entry?.acceptance === "continue") return "no_confirmation"; if (entry?.acceptance === "review") return "user_confirmed"; - // 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑) return "user_confirmed"; } @@ -210,6 +214,11 @@ export function buildDeclaredWorkerSkill(params: { params.template?.duty?.trim() || `执行 ${id}`; const excerpt = params.template?.prompt_excerpt?.trim() || ""; + const visibleBodyRule = + id === "narrator" || id === "round-present" + ? PLAY_VISIBLE_BODY_INSTRUCTION + : ""; + const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n"); const presentation = params.entry.presentation ? JSON.stringify(params.entry.presentation, null, 2) : ""; @@ -217,7 +226,7 @@ export function buildDeclaredWorkerSkill(params: { const premises = (params.workerSet?.core_premises ?? []).filter(Boolean); const residentSection = formatResidentPromptSection(resident, id); - const personaText = [duty, excerpt].filter(Boolean).join("\n\n"); + const personaText = [duty, excerptWithRule].filter(Boolean).join("\n\n"); const orderDoc = parseContextOrder(params.workerSet?.context_order); const orderSlot = slotOrderForRef(orderDoc, id); let contextSegments: ContextSegmentDef[] | undefined; @@ -277,7 +286,7 @@ export function buildDeclaredWorkerSkill(params: { "", duty, "", - excerpt ? `## 写法要点\n\n${excerpt}` : "", + excerptWithRule ? `## 写法要点\n\n${excerptWithRule}` : "", presentation ? `## presentation(实例)\n\n\`\`\`json\n${presentation}\n\`\`\`` : "", narrative ? `## 叙事指南\n\n${narrative}` : "", premises.length diff --git a/src/skills/loader.ts b/src/skills/loader.ts index c84abbf..050c0d7 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -516,7 +516,9 @@ export async function loadWorkerSkillWithContext( resolveDesignStepBinding, CREATION_CURRENT_STEP_TAG, CREATION_MODULE_OPENING_TAG, + formatPriorArtifactContext, formatStepParamsForPrompt, + isPriorArtifactModule, } = await import("./creation-flow.js"); const binding = await resolveDesignStepBinding({ skillPackRoot: skill.skillPackRoot, @@ -526,11 +528,27 @@ export async function loadWorkerSkillWithContext( skillsRoot, }); if (binding) { + const inheritTag = binding.inheritTag?.trim() || ""; + const inheritNote = inheritTag + ? `\n\n【回头修改】本步继承既有产物继续改,不是从零再生成。程序已注入【既有产物 · 继承修改】。保留未点名要改的部分,写回同一产物。禁止推倒重写,禁止再走一遍开场询问。` + : ""; const openingNote = binding.opening ? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。` : ""; - const paramsBlock = `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`; - modulePromptBlock = `${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}`; + const paramsBlock = isPriorArtifactModule(binding.module) + ? formatPriorArtifactContext(binding.step.params) + : `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`; + let rosterBlock = ""; + const depProjection = + binding.module.id === "context-order" ? "index" : undefined; + if (binding.module.id === "context-order") { + const { formatPlayAgentRosterForPrompt } = await import("./play-slots.js"); + rosterBlock = `${formatPlayAgentRosterForPrompt()}\n\n`; + } + modulePromptBlock = `${rosterBlock}${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}${inheritNote}`; + const depTags = inheritTag + ? binding.depTags.filter((tag) => tag !== inheritTag) + : binding.depTags; const baseInputs = [ "用户.需求", "book.brief", @@ -540,16 +558,29 @@ export async function loadWorkerSkillWithContext( "设计.创作流程", CREATION_CURRENT_STEP_TAG, CREATION_MODULE_OPENING_TAG, - ...binding.depTags, + ...depTags, + ...(inheritTag ? [inheritTag] : []), ]; const inputTags = [...new Set(baseInputs)]; const outputTags = [binding.module.artifact]; - const depSegments = binding.depTags.map((tag, i) => ({ + const depSegments = depTags.map((tag, i) => ({ id: `dep-${i}`, tier: "static" as const, tags: [tag], - label: `## 【依赖产物 · ${tag}】只读`, + label: `## 【依赖产物 · ${tag}】只读${depProjection === "index" ? "(仅 brief / 挂载 / 稳变)" : ""}`, + ...(depProjection ? { projection: depProjection } : {}), })); + const inheritSegment = inheritTag + ? [ + { + id: "inherit-existing", + tier: "static" as const, + tags: [inheritTag], + label: + "## 【既有产物 · 继承修改】在下列正文上继续改,写回同一产物;禁止从零另起一份", + }, + ] + : []; const openingSegment = binding.opening ? [ { @@ -562,13 +593,16 @@ export async function loadWorkerSkillWithContext( : []; patchedWorker = { ...worker, - name: `创作 · ${binding.module.name}`, + name: inheritTag + ? `创作 · 回头修改 · ${binding.module.name}` + : `创作 · ${binding.module.name}`, description: binding.module.declaration, inputTags, outputTags, contextSegments: [ ...(worker.contextSegments ?? []), ...openingSegment, + ...inheritSegment, ...depSegments, ], }; diff --git a/src/skills/opening-seal.ts b/src/skills/opening-seal.ts new file mode 100644 index 0000000..4d63d3f --- /dev/null +++ b/src/skills/opening-seal.ts @@ -0,0 +1,200 @@ +/** + * 开场白终节点:选定后落库、关 DAG。 + * 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。 + */ +import { + parseTableDoc, + stringifyTableDoc, + type TableDoc, +} from "../blackboard/table-cells.js"; +import { + parseCreationFlow, + stringifyCreationFlow, +} from "./creation-flow.js"; +import { parseContextFragment } from "./context-fragment.js"; + +export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量"; +export const OPENING_OUTPUT_TAG = "输出.开场白"; +export const OPENING_INITIAL_VARS_TAG = "运行.初始变量"; +export const OPENING_CURRENT_VARS_TAG = "变量.当前"; + +export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening"; +export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex"; + +export const CREATION_SEALED_WAITING_MESSAGE = + "开场已选定,创作已收口并保存。可切换到「游玩」。"; + +export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)"; + +export function isOpeningLockTag(tag: string): boolean { + const t = tag.trim(); + return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG; +} + +export function isOpeningSealArtifact(artifact: { + workerId?: string; + outputTags?: readonly string[]; +}): boolean { + if ((artifact.workerId ?? "").trim() === "opening-generator") return true; + return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG); +} + +export type OpeningVariable = { + name: string; + value: unknown; + note?: string; +}; + +export type OpeningSealPayload = { + candidates: string[]; + selectedIndex: number; + selectedText: string; + variables: OpeningVariable[]; +}; + +function asTrimmed(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function collectCandidateTexts(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + for (const item of raw) { + if (typeof item === "string" && item.trim()) { + out.push(item.trim()); + continue; + } + if (item && typeof item === "object" && !Array.isArray(item)) { + const row = item as Record; + const text = + asTrimmed(row.全文) || + asTrimmed(row.text) || + asTrimmed(row.开场白) || + asTrimmed(row.内容); + if (text) out.push(text); + } + } + return out; +} + +function collectVariables(raw: unknown): OpeningVariable[] { + if (!Array.isArray(raw)) return []; + const out: OpeningVariable[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const name = + asTrimmed(row.名) || asTrimmed(row.name) || asTrimmed(row.key) || ""; + if (!name) continue; + const note = asTrimmed(row.依据) || asTrimmed(row.note); + out.push({ + name, + value: row.值 !== undefined ? row.值 : row.value, + ...(note ? { note } : {}), + }); + } + return out; +} + +function bodyRecord(fragmentBody: unknown): Record | null { + if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) { + return null; + } + return fragmentBody as Record; +} + +/** 从开场白 fragment(或裸 JSON)抽出候选与变量。 */ +export function parseOpeningSealPayload( + raw: string | null | undefined, + selectedIndex = 0, +): OpeningSealPayload | null { + if (!raw?.trim()) return null; + const fragment = parseContextFragment(raw); + const body = bodyRecord(fragment?.正文) ?? (() => { + try { + const parsed = JSON.parse(raw) as unknown; + return bodyRecord(parsed) ?? bodyRecord((parsed as { 正文?: unknown })?.正文); + } catch { + return null; + } + })(); + + const primary = + asTrimmed(body?.开场白全文) || + asTrimmed(body?.["输出.开场白"]) || + (typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined); + + const fromList = collectCandidateTexts(body?.开场白候选); + const candidates: string[] = []; + const seen = new Set(); + const push = (text: string | undefined) => { + if (!text || seen.has(text)) return; + seen.add(text); + candidates.push(text); + }; + push(primary); + for (const t of fromList) push(t); + if (candidates.length === 0) return null; + + const max = candidates.length - 1; + const idx = Number.isFinite(selectedIndex) + ? Math.min(max, Math.max(0, Math.trunc(selectedIndex))) + : 0; + + return { + candidates, + selectedIndex: idx, + selectedText: candidates[idx] ?? candidates[0]!, + variables: collectVariables(body?.开场变量), + }; +} + +export function openingVariablesToTableDoc( + variables: readonly OpeningVariable[], + source = "worker:opening-setup", +): TableDoc { + const now = new Date().toISOString(); + return { + rows: variables.map((v) => ({ + key: v.name, + value: v.value, + rev: 1, + updatedAt: now, + source, + visibility: "visible" as const, + ...(v.note ? { note: v.note } : {}), + })), + }; +} + +export function mergeOpeningTablePatch( + currentRaw: string | null | undefined, + variables: readonly OpeningVariable[], + source = "worker:opening-setup", +): string | null { + if (!variables.length) return null; + const patch = openingVariablesToTableDoc(variables, source); + const current = parseTableDoc(currentRaw); + if (!current) return stringifyTableDoc(patch); + const byKey = new Map(current.rows.map((r) => [r.key, r])); + const now = new Date().toISOString(); + for (const row of patch.rows) { + const prev = byKey.get(row.key); + if (prev && String(prev.source).startsWith("user")) continue; + byKey.set(row.key, { + ...row, + rev: (prev?.rev ?? 0) + 1, + updatedAt: now, + }); + } + return stringifyTableDoc({ rows: [...byKey.values()] }); +} + +export function closeCreationFlowRaw(raw: string | null | undefined): string | null { + const flow = parseCreationFlow(raw); + if (!flow) return raw?.trim() ? raw : null; + if (flow.status === "closed") return stringifyCreationFlow(flow); + return stringifyCreationFlow({ ...flow, status: "closed" }); +} diff --git a/src/skills/play-slots.ts b/src/skills/play-slots.ts index 2890969..215e81b 100644 --- a/src/skills/play-slots.ts +++ b/src/skills/play-slots.ts @@ -319,3 +319,54 @@ export function inferPlaySlotsFromWorkers( chance: hasChance, }; } + +const AGENT_ID_ALIASES: Record = { + auditor: "auditor", + 旁观维护: "auditor", + 表格管理: "auditor", + gm: "gm", + "world-simulator": "gm", + 主世界层: "gm", + narrator: "narrator", + 叙事转述: "narrator", + 转述: "narrator", + perspective: "perspective", + "role-decide": "perspective", + 角色视角: "perspective", + chance: "chance", + 机遇裁定: "chance", +}; + +/** 把模型写的 id / 中文名 / worker ref 收成槽键 */ +export function resolvePlayAgentId( + raw: string, +): PlaySlotId | OnDemandSlotId | undefined { + const t = raw.trim(); + return AGENT_ID_ALIASES[t] ?? AGENT_ID_ALIASES[t.toLowerCase()]; +} + +/** 注入投影排序等晚段步骤:名册由程序钉死,模型只抄不发明。 */ +export function formatPlayAgentRosterForPrompt(): string { + const lines = [ + "## 【本局固定执行单元 · 程序钉死】", + "", + "禁止增删改名,禁止发明未入白名单的槽(如画图)。先抄名册,再给每个已启用者分配上下文 inserts。", + "", + "每轮管线(顺序固定):", + ]; + for (const id of PLAY_SLOT_ORDER) { + const meta = PLAY_SLOT_META[id]; + const def = id === "perspective" ? "默认关" : "默认开"; + lines.push( + `- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`, + ); + } + lines.push("", "按需(不进每轮;enabled 时可空 inserts):"); + for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) { + const meta = ON_DEMAND_SLOT_META[id]; + lines.push( + `- \`${id}\` → ref=\`${DEFAULT_ON_DEMAND_REFS[id]}\` **${meta.label}**(默认关):${meta.purpose}`, + ); + } + return lines.join("\n"); +} diff --git a/src/skills/play-turn.ts b/src/skills/play-turn.ts new file mode 100644 index 0000000..4f79666 --- /dev/null +++ b/src/skills/play-turn.ts @@ -0,0 +1,33 @@ +/** + * 游玩回合:按运行规格 playWorkerIds 连跑(旁观 → 视角? → 主世界层 → 转述)。 + * 与创作总管无关;收口后的输入走这里。 + */ +export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue"; +export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive"; + +export const PLAY_WORKING_SNAPSHOT_ID = "play-working"; +export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩"; + +export function isPlayLayerActive(slots: Record | undefined): boolean { + return Boolean(slots?.[SLOT_PLAY_LAYER_ACTIVE]); +} + +export function readPlayTurnQueue( + slots: Record | undefined, +): string[] { + const raw = slots?.[SLOT_PLAY_TURN_QUEUE]; + if (!Array.isArray(raw)) return []; + return raw + .filter((id): id is string => typeof id === "string" && Boolean(id.trim())) + .map((id) => id.trim()); +} + +export function withPlayTurnQueue( + slots: Record, + queue: string[] | undefined, +): Record { + return { + ...slots, + [SLOT_PLAY_TURN_QUEUE]: queue?.length ? queue : undefined, + }; +} diff --git a/src/skills/present-packet.ts b/src/skills/present-packet.ts index 46acc79..5cb56f0 100644 --- a/src/skills/present-packet.ts +++ b/src/skills/present-packet.ts @@ -132,6 +132,9 @@ function normalizeBlocks(raw: unknown): PresentBlocks { if (out.monitor === undefined && row.监控 !== undefined) { out.monitor = row.监控 as PresentBlocks["monitor"]; } + if (typeof out.body === "string") { + out.body = stripPresentSourceFences(out.body); + } return out; } @@ -170,15 +173,35 @@ export function presentFromPlainText( return { schema: PRESENT_SCHEMA, shell, - blocks: { body: text }, + blocks: { body: stripPresentSourceFences(text) }, }; } +/** 模型常把 tag 名写进正文开头;展示前剥掉 */ +export function stripPresentSourceFences(raw: string): string { + let s = raw.trim(); + for (let i = 0; i < 3; i += 1) { + const next = s + .replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "") + .replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "") + .trim(); + if (next === s) break; + s = next; + } + return s; +} + +export const PLAY_VISIBLE_BODY_INSTRUCTION = [ + "用户可见正文(present.v1 的 blocks.body,或纯 Markdown 主读)默认 **500~2000 字**(按汉字计)。", + "不要写成几句气泡短信就结束,除非用户明确要求极短。", + "禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。", +].join("\n"); + export function parsePresentPacket( raw: string | undefined | null, fallbackShell: PresentShellId = "prose", ): PresentPacketView { - const text = raw?.trim() ?? ""; + const text = stripPresentSourceFences(raw?.trim() ?? ""); if (!text) { return { ok: true, diff --git a/src/skills/worker-declaration.ts b/src/skills/worker-declaration.ts index 36bbd7e..b1764c2 100644 --- a/src/skills/worker-declaration.ts +++ b/src/skills/worker-declaration.ts @@ -1,5 +1,6 @@ import type { Blackboard } from "../blackboard/blackboard.js"; import type { RuntimeSession } from "../types/runtime.js"; +import { isPlayLayerActive } from "./play-turn.js"; import { deriveDesignStageScope, deriveOnDemandWorkerScope, @@ -45,8 +46,9 @@ export function canEnterPlay(session: RuntimeSession): boolean { } export function inferLifecycleStage(session: RuntimeSession): LifecycleStage { - const override = session.slots.uiLifecycleStage; - if (override === "play" && canEnterPlay(session)) return "play"; + if (!canEnterPlay(session)) return "design"; + if (session.slots.uiLifecycleStage === "play") return "play"; + if (isPlayLayerActive(session.slots)) return "play"; return "design"; } diff --git a/src/skills/worker-llm.ts b/src/skills/worker-llm.ts index a47b940..30a323b 100644 --- a/src/skills/worker-llm.ts +++ b/src/skills/worker-llm.ts @@ -3,16 +3,13 @@ import { OpenAiCompatibleProvider, type LlmProvider, } from "../llm/client.js"; -import { PresetLlmProvider } from "../llm/preset-wrapper.js"; -import { resolveActivePreset } from "../preset/store.js"; -import { loadAppSettings } from "../config/settings.js"; import { - TokenTrackingProvider, - type LlmTrackingContext, -} from "../llm/token-tracker.js"; + wrapLlmForSession, + type LlmTrackingRef, +} from "../llm/preset-wrapper.js"; import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js"; -type LlmTrackingRef = { current: LlmTrackingContext }; +export type { LlmTrackingContext } from "../llm/token-tracker.js"; /** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */ export function createLlmForProfileId( @@ -25,17 +22,10 @@ export function createLlmForProfileId( return fallback; } - let inner: LlmProvider = new OpenAiCompatibleProvider( - profileToLlmConfig(profile), + return wrapLlmForSession( + new OpenAiCompatibleProvider(profileToLlmConfig(profile)), + trackingRef, ); - const preset = resolveActivePreset(loadAppSettings().activePresetId); - if (preset) { - inner = new PresetLlmProvider(inner, () => - resolveActivePreset(loadAppSettings().activePresetId), - ); - } - if (!trackingRef) return inner; - return new TokenTrackingProvider(inner, () => trackingRef.current); } /** @@ -142,5 +132,3 @@ export function filterInputsForRolePerspective( } return filtered; } - -export type { LlmTrackingContext }; diff --git a/src/types/context-trace.ts b/src/types/context-trace.ts index ff2ec83..e891122 100644 --- a/src/types/context-trace.ts +++ b/src/types/context-trace.ts @@ -1,3 +1,5 @@ +import type { GenerationParameters } from "./preset.js"; + /** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */ export type LlmContextMessage = { role: string; @@ -12,17 +14,51 @@ export type LlmContextTrace = { /** 便于列表展示 */ charCount: number; model?: string; + /** 与请求一并送出的生成参数(无则未带) */ + generation?: GenerationParameters; }; +/** 把即将写入请求 body.messages 的条目冻成痕迹(含 tool_calls 等附加字段) */ +export function snapshotRequestMessages( + messages: Array<{ + role: string; + content?: string | null; + tool_calls?: unknown; + tool_call_id?: string; + }>, +): LlmContextMessage[] { + return messages.map((m) => { + const hasExtras = Boolean(m.tool_calls) || Boolean(m.tool_call_id); + if (!hasExtras) { + return { + role: m.role, + content: typeof m.content === "string" ? m.content : String(m.content ?? ""), + }; + } + return { + role: m.role, + content: JSON.stringify({ + role: m.role, + content: m.content ?? "", + ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), + ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), + }), + }; + }); +} + export function buildContextTrace(params: { caller: string; - messages: Array<{ role: string; content: string }>; + messages: Array<{ + role: string; + content?: string | null; + tool_calls?: unknown; + tool_call_id?: string; + }>; model?: string; + generation?: GenerationParameters; }): LlmContextTrace { - const messages = params.messages.map((m) => ({ - role: m.role, - content: typeof m.content === "string" ? m.content : String(m.content ?? ""), - })); + const messages = snapshotRequestMessages(params.messages); const charCount = messages.reduce((n, m) => n + m.content.length, 0); return { caller: params.caller, @@ -30,6 +66,9 @@ export function buildContextTrace(params: { messages, charCount, model: params.model, + ...(params.generation && Object.keys(params.generation).length + ? { generation: params.generation } + : {}), }; } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index f0bd4dd..b6638ae 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -53,7 +53,10 @@ export type WaitingReason = } // worker 无产物时的阻塞提问 | { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑 | { - /** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */ + /** + * 旧路径:上一步验收后先问「下一步想写什么」。 + * 现已改为验收后直接提案;保留此 kind 以兼容未完成的旧会话。 + */ kind: "next_intent"; afterWorkerId?: string; afterUnitId?: string; @@ -329,7 +332,13 @@ export type PhaseEffect = | { type: "run_programmatic_review"; artifactId: string } | { type: "emit_message"; message: string } /** 根据流程与用户下一步意向,提案下一节点并进入确认 */ - | { type: "propose_next_creation_step" }; + | { type: "propose_next_creation_step" } + /** 选定开场白:落库、关 DAG、标记创作收口 */ + | { type: "seal_creation_opening" } + /** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */ + | { type: "run_play_turn" } + /** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */ + | { type: "continue_play_turn" }; /** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */ export type ApplyEventResult = { diff --git a/src/worker/executor.ts b/src/worker/executor.ts index 4e1f159..b328700 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -11,9 +11,16 @@ import type { ParsedWorkerSkill } from "../skills/types.js"; import { extractJsonObjectText, isUsableWorkerSet, + looksLikeProseNotSpec, parseWorkerSetYaml, } from "../skills/worker-set-parse.js"; import { assembleWorkerContext } from "../skills/context-segments.js"; +import { isPlayLayerActive } from "../skills/play-turn.js"; +import { loadAppSettings } from "../config/settings.js"; +import { resolveActivePreset } from "../preset/store.js"; +import { assemblePlayWorkerMessages } from "../preset/play-frame.js"; +import { worldInfoPackFromSegments } from "../preset/world-info-pack.js"; +import { PRESENT_TAG } from "../skills/present-packet.js"; import { mergeQuestionsPreferFragment, normalizeQuestions, @@ -21,11 +28,15 @@ import { } from "../skills/question-protocol.js"; import { extractFragmentAskSidecar, - expectsContextFragmentTag, isUsableContextFragment, looksLikeFragmentDoc, } from "../skills/context-fragment.js"; import { isProgressPointerTag } from "../skills/creation-flow.js"; +import { + CONTEXT_ORDER_TAG, + parseContextOrder, + serializeContextOrder, +} from "../skills/context-order.js"; export type WorkerRunParams = { skillName: string; @@ -53,6 +64,25 @@ export type WorkerRunResult = { }; const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]); +const PLAY_FEED_OUTPUT_TAGS = new Set([PRESENT_TAG, "输出.开场白"]); + +function formatWorkerPreview( + outputs: Record, + summary: string, +): string { + const visible = Object.entries(outputs).filter(([tag]) => + PLAY_FEED_OUTPUT_TAGS.has(tag), + ); + if (visible.length) { + return visible.map(([, v]) => v).join("\n\n"); + } + return ( + Object.entries(outputs) + .map(([tag, v]) => `### ${tag}\n\n${v}`) + .join("\n\n") + .slice(0, 4000) || summary + ); +} const WORKER_OUTPUT_INSTRUCTION = ` @@ -74,7 +104,9 @@ askUser 每项可为: - outputs 的 key 必须是要求的 outputTags - **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML - **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针 -- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser +- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答) +- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问 +- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser - 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段 - 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 - summary 用于界面展示`; @@ -193,31 +225,46 @@ function recoverFragmentOutputs( } } -function dropUnusableFragmentOutputs(outputs: Record): void { +/** 进度指针不得当产物;半残 JSON / 缺字段仍保留,交给验收卡。 */ +function dropProgressPointerOutputs(outputs: Record): void { for (const tag of Object.keys(outputs)) { - if (isProgressPointerTag(tag)) { - delete outputs[tag]; - continue; - } - const content = outputs[tag]!; - const requireFragment = expectsContextFragmentTag(tag); - const looksFragment = - isUsableContextFragment(content) || - looksLikeFragmentDoc(tryParseObject(content)) || - /"schema"\s*:\s*"context-fragment\.v1"/.test(content) || - /"技能"\s*:/.test(content); - if ((requireFragment || looksFragment) && !isUsableContextFragment(content)) { + if (isProgressPointerTag(tag) || !outputs[tag]?.trim()) { delete outputs[tag]; } } } -function tryParseObject(text: string): unknown { - const extracted = extractJsonObjectText(text) ?? text.trim(); - try { - return JSON.parse(extracted); - } catch { - return undefined; +/** 像结构化残稿(含截断 JSON),应进验收而不是改成提问。 */ +function looksLikeStructuredDraft(text: string): boolean { + const t = text.trim(); + if (!t) return false; + if (t.startsWith("{") || t.startsWith("[")) return true; + if (/```(?:json)?/i.test(t) && t.includes("{")) return true; + if (/"schema"\s*:/.test(t)) return true; + if (/"技能"\s*:/.test(t) || /"正文"\s*:/.test(t)) return true; + if (/"inserts"\s*:/.test(t) || /"agents"\s*:/.test(t) || /"play_slots"\s*:/.test(t)) { + return true; + } + if (/"workers"\s*:/.test(t) && t.includes("{")) return true; + return Boolean(extractJsonObjectText(t)); +} + +/** 几乎只有提问、没有稿。 */ +function isQuestionOnlyText(text: string): boolean { + const t = text.trim(); + if (!t) return true; + if (looksLikeStructuredDraft(t)) return false; + const qs = extractQuestionsFromText(t); + if (!qs.length) return false; + return looksLikeProseNotSpec(t) || /你的选择|请(?:描述|选择|确认|补充)/.test(t); +} + +function canonicalizeKnownOutputs(outputs: Record): void { + const raw = outputs[CONTEXT_ORDER_TAG]; + if (!raw?.trim()) return; + const doc = parseContextOrder(raw); + if (doc?.slots.length) { + outputs[CONTEXT_ORDER_TAG] = serializeContextOrder(doc); } } @@ -239,20 +286,14 @@ function parseWorkerResponse( try { parsed = JSON.parse(raw); } catch { - if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) { - const questions = normalizeQuestions(extractQuestionsFromText(raw)); - return { - outputs: {}, - summary: "未产出合法协议 JSON", - preview: raw.slice(0, 600), - askUser: questions.length - ? questions.slice(0, 2) - : normalizeQuestions([ - "请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。", - ]), - }; - } const target = productOutputTags(outputTags)[0] ?? outputTags[0]; + if (target && looksLikeStructuredDraft(raw)) { + return finalizeParsedOutputs( + { [target]: raw }, + { summary: "格式不完整,已交出残稿" }, + outputTags, + ); + } if (target && isUsableContextFragment(raw)) { return finalizeParsedOutputs( { [target]: raw }, @@ -260,6 +301,13 @@ function parseWorkerResponse( outputTags, ); } + if (target && raw.trim() && !isQuestionOnlyText(raw)) { + return finalizeParsedOutputs( + { [target]: raw }, + { summary: "未按协议包一层,已交出原文" }, + outputTags, + ); + } const questions = normalizeQuestions(extractQuestionsFromText(raw)); return { outputs: {}, @@ -323,7 +371,7 @@ function finalizeParsedOutputs( } } - // 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop + // 先抽追问;半残稿仍留在 outputs,追问挂验收卡(不因缺字段丢掉正文) let askAssessment: string | undefined; const fragQuestions: QuestionItem[] = []; for (const content of Object.values(outputs)) { @@ -339,7 +387,8 @@ function finalizeParsedOutputs( askUser = mergeQuestionsPreferFragment(askUser, fragQuestions); } - dropUnusableFragmentOutputs(outputs); + dropProgressPointerOutputs(outputs); + canonicalizeKnownOutputs(outputs); if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) { askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK); @@ -351,11 +400,7 @@ function finalizeParsedOutputs( : Object.values(outputs)[0]?.slice(0, 80) ?? (askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成"); - const preview = - Object.entries(outputs) - .map(([tag, v]) => `### ${tag}\n\n${v}`) - .join("\n\n") - .slice(0, 4000) || summary; + const preview = formatWorkerPreview(outputs, summary); return sanitizeWorkerSetOutputs({ outputs, @@ -366,12 +411,11 @@ function finalizeParsedOutputs( }); } -/** 规格 tag 必须是可用 JSON;非法散文改为 askUser,避免污染黑板 */ +/** 规格 tag:能解析则收成 JSON;半残 JSON 保留进验收;纯提问才改成 askUser。 */ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult { const outputs = { ...result.outputs }; const askUser = [...(result.askUser ?? [])]; const askAssessment = result.askAssessment; - let droppedProse = false; for (const tag of [...Object.keys(outputs)]) { if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue; @@ -388,27 +432,16 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu : (extractJsonObjectText(extracted) ?? extracted); continue; } - droppedProse = true; - delete outputs[tag]; - if (askUser.length === 0) { - const qs = normalizeQuestions(extractQuestionsFromText(content)); - if (qs.length) askUser.push(...qs.slice(0, 2)); + if (looksLikeStructuredDraft(content)) { + outputs[tag] = content.trim(); + continue; } - } - - if (droppedProse && askUser.length === 0) { - askUser.push( - ...normalizeQuestions([ - "请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON)。", - ]), - ); - } - - // 提问时不要夹带半残规格 - if (askUser.length > 0) { - for (const tag of WORKER_SET_OUTPUT_TAGS) { - // 保留仍合法的草稿;已在上面删掉非法的 - void tag; + if (isQuestionOnlyText(content)) { + delete outputs[tag]; + if (askUser.length === 0) { + const qs = normalizeQuestions(extractQuestionsFromText(content)); + if (qs.length) askUser.push(...qs.slice(0, 2)); + } } } @@ -420,10 +453,7 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu const preview = askUser.length && Object.keys(outputs).length === 0 ? askUser.map((q) => `- ${q.prompt}`).join("\n") - : Object.entries(outputs) - .map(([tag, v]) => `### ${tag}\n\n${v}`) - .join("\n\n") - .slice(0, 4000) || summary; + : formatWorkerPreview(outputs, summary); return { outputs, @@ -527,22 +557,41 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { outputTags: ["设计.美学纲领与交互范式"], }); expect(text).toContain("待改底稿"); - expect(text).toContain("用户.worker答复"); + expect(text).toContain("用户要求"); + expect(text).toContain("冲突时以较新的为准"); expect(text).toContain("偏冷"); expect(text).toContain("再冷一点"); + expect(text.split("再冷一点").length - 1).toBe(1); + }); + + it("stale revision note without a draft stays as a user requirement, not revision mode", () => { + const bb = new Blackboard(); + const text = assembleWorkerContext({ + inputs: { + "用户.修订说明": "上一步留下的意见", + "用户.需求": "网恋对话", + }, + segments: [ + { + id: "user", + tier: "dynamic", + tags: ["用户.需求", "用户.修订说明"], + label: "## 用户表述", + }, + ], + blackboard: bb, + workerId: "design-step", + workerName: "创作 · 正文组成", + outputTags: ["设计.正文组成"], + }); + expect(text).not.toContain("修订模式"); + expect(text).not.toContain("待改底稿"); + expect(text).toContain("用户要求"); + expect(text).toContain("上一步留下的意见"); + expect(text).toContain("网恋对话"); + expect(text).toContain("按 SKILL 与上方分区完成任务"); + }); + + it("collapses duplicate user-demand tags in the same segment", () => { + const bb = new Blackboard(); + const text = assembleWorkerContext({ + inputs: { + "用户.需求": "开场需求\n\n需要正文和选项", + "用户.worker答复": "需要正文和选项", + "用户.最新输入": "需要正文和选项", + }, + segments: [ + { + id: "user", + tier: "dynamic", + tags: ["用户.需求", "用户.worker答复", "用户.最新输入"], + label: "## 用户表述", + }, + ], + blackboard: bb, + workerId: "design-step", + workerName: "创作 · 正文组成", + outputTags: ["设计.正文组成"], + }); + expect(text.split("需要正文和选项").length - 1).toBe(1); + }); + + it("keeps earlier user requirements and puts the newer revision last", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.正文组成", + content: '{"shell":"split_board"}', + source: "worker", + }); + const text = assembleWorkerContext({ + inputs: { + "用户.需求": "需要正文和监控", + "用户.修订说明": "状态放到上面", + "设计.正文组成": '{"shell":"split_board"}', + }, + segments: [ + { + id: "user", + tier: "dynamic", + tags: ["用户.需求", "用户.修订说明"], + label: "## 用户表述", + }, + ], + blackboard: bb, + workerId: "design-step", + workerName: "创作 · 正文组成", + outputTags: ["设计.正文组成"], + }); + expect(text).toContain("需要正文和监控"); + expect(text).toContain("状态放到上面"); + expect(text.indexOf("需要正文和监控")).toBeLessThan(text.indexOf("状态放到上面")); + expect(text).toContain("冲突时以较新的为准"); + expect(text).toContain("待改底稿"); + expect(text).toContain("split_board"); + }); + + it("inherit-existing asks the worker to keep the draft without a revision note", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.美学纲领与交互范式", + content: '{"tone":"warm"}', + source: "worker", + }); + const text = assembleWorkerContext({ + inputs: { + "设计.美学纲领与交互范式": '{"tone":"warm"}', + }, + segments: [ + { + id: "inherit-existing", + tier: "static", + tags: ["设计.美学纲领与交互范式"], + label: "## 【既有产物 · 继承修改】在下列正文上继续改", + }, + ], + blackboard: bb, + workerId: "design-step", + workerName: "创作 · 回头修改 · 美学纲领与交互范式", + outputTags: ["设计.美学纲领与交互范式"], + }); + expect(text).toContain("既有产物 · 继承修改"); + expect(text).toContain("回头修改"); + expect(text).toContain("warm"); + expect(text).not.toContain("按 SKILL 与上方分区完成任务"); }); it("falls back to JSON inputs without segments", () => { diff --git a/tests/context-fragment.test.ts b/tests/context-fragment.test.ts index e3b1cb9..45a4ff5 100644 --- a/tests/context-fragment.test.ts +++ b/tests/context-fragment.test.ts @@ -77,6 +77,24 @@ describe("context-fragment.v1", () => { ]); }); + it("does not surface empty 追问 导语 as sidecar assessment", () => { + const side = extractFragmentAskSidecar({ + schema: "context-fragment.v1", + brief: "返现都市", + 正文: { 美学纲领: { 体验内核: "被宠" } }, + 自评: { + 维度: [{ 名: "交互范式", 分数: 9, 说明: "站位已清" }], + }, + 追问: { + 导语: "为贴近你要的质感,还需确认:", + 题目: [], + }, + }); + expect(side.questions).toHaveLength(0); + expect(side.assessment).toContain("交互范式 9/10"); + expect(side.assessment).not.toContain("还需确认"); + }); + it("isUsableContextFragment requires non-empty 正文", () => { expect( isUsableContextFragment({ @@ -125,6 +143,63 @@ describe("context-order.v1", () => { expect(segs[3].tier).toBe("dynamic"); }); + it("accepts agents[] roster and maps gm → world-simulator", () => { + const doc = parseContextOrder({ + schema: "context-order.v1", + brief: "旁观+主世界层+转述", + agents: [ + { + id: "auditor", + label: "表格管理", + enabled: true, + when: "every_turn", + inserts: [ + { order: 0, ref: "worker.persona", projection: "fixed" }, + { order: 1, ref: "设计.变量设计与更新规则", projection: "summary" }, + ], + }, + { + id: "gm", + enabled: true, + inserts: [ + { order: 0, ref: "worker.persona", projection: "fixed" }, + { order: 1, ref: "对话.历史", projection: "summary" }, + ], + }, + { id: "narrator", enabled: true, inserts: [] }, + { id: "perspective", enabled: false, inserts: [] }, + { id: "chance", enabled: false, when: "on_demand", inserts: [] }, + ], + })!; + expect(doc.play_slots?.auditor).not.toBe(false); + expect(doc.play_slots?.gm).toBe(true); + expect(doc.play_slots?.perspective).toBe(false); + expect(doc.play_slots?.chance).toBe(false); + expect(slotOrderForRef(doc, "auditor")?.label).toBe("表格管理"); + expect(slotOrderForRef(doc, "world-simulator")?.inserts.some((i) => i.ref === "对话.历史")).toBe( + true, + ); + expect(slotOrderForRef(doc, "role-decide")).toBeUndefined(); + }); + + it("index projection keeps brief, mount and 稳变 without 正文", () => { + const text = projectFragmentContent( + JSON.stringify({ + schema: "context-fragment.v1", + 技能: "美学纲领与交互范式", + brief: "免疫即权力", + mount: ["world-simulator", "narrator"], + 稳变: "stable", + 正文: { 美学纲领: { 体验内核: "很长不应出现在 index 里的正文" } }, + }), + "index", + ); + expect(text).toContain("免疫即权力"); + expect(text).toContain("挂载:world-simulator、narrator"); + expect(text).toContain("稳变:stable"); + expect(text).not.toContain("很长不应出现在 index 里的正文"); + }); + it("reorders flat list and merges into worker set JSON", () => { const doc = parseContextOrder({ schema: "context-order.v1", diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index 6a85155..dfbf727 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -290,12 +290,21 @@ describe("creation-flow", () => { expect( catalog?.modules.find((m) => m.name === "具体实例")?.repeatable, ).toBe(true); + expect( + catalog?.modules.find((m) => m.name === "生成规则")?.kind, + ).toBe("prior-artifact"); + expect( + catalog?.modules.find((m) => m.name === "具体实例")?.kind, + ).toBe("prior-artifact"); expect( catalog?.modules.find((m) => m.name === "生成规则")?.params?.[0]?.key, ).toBe("target"); expect( catalog?.modules.find((m) => m.name === "具体实例")?.params?.[0]?.key, ).toBe("rule_id"); + expect( + catalog?.modules.find((m) => m.name === "上下文投影排序")?.auto, + ).toBe(true); const gen = catalog?.modules.find((m) => m.name === "生成规则"); expect(gen?.when).toBeTruthy(); expect(gen?.when_not).toBeTruthy(); @@ -303,9 +312,12 @@ describe("creation-flow", () => { const block = formatModuleCatalogForAgent(catalog!); expect(block).toContain("【能力"); expect(block).toContain("美学纲领与交互范式:"); - expect(block).toContain("生成规则〔可反复〕"); + expect(block).toContain("生成规则〔可反复〕〔先验产物〕"); + expect(block).toContain("开场白与开场变量〔收口〕"); + expect(block).toContain("上下文投影排序〔程序步〕"); expect(block).toContain("何时用"); expect(block).toContain("何时不用"); + expect(block).toContain("规划产物字段"); expect(block).toContain("编排参数"); expect(block).not.toContain("设计.美学纲领与交互范式"); }); @@ -465,7 +477,8 @@ recipes: expect(loaded.promptBody).toContain("已完成:"); expect(loaded.promptBody).toContain("美学纲领与交互范式"); expect(loaded.promptBody).toContain("已验收"); - expect(loaded.promptBody).toContain("禁止再排一次执行"); + expect(loaded.promptBody).toContain("禁止再排新建"); + expect(loaded.promptBody).toContain("mode=revise"); expect(loaded.promptBody).toContain("生成规则"); }); @@ -506,6 +519,7 @@ recipes: expect(loaded.promptBody).toContain("美学纲领与交互范式"); expect(loaded.promptBody).toContain("体验契约"); expect(loaded.promptBody).toContain("程序开场"); + expect(loaded.promptBody).toContain("【本步参数】"); expect(loaded.worker.inputTags).toContain("创作.能力开场白"); }); @@ -606,24 +620,24 @@ recipes: expect(view.steps[0]?.params?.target).toBe("怪物"); const catalog = await loadModuleCatalog("dialogue/world-simulator"); - expect( - catalog?.modules.find((m) => m.name === "生成规则")?.params?.some( - (p) => p.key === "target" && p.required, - ), - ).toBe(true); + const rules = catalog?.modules.find((m) => m.name === "生成规则"); + expect(rules?.kind).toBe("prior-artifact"); + expect(rules?.params?.some((p) => p.key === "target")).toBe(true); expect(validateCreationFlow(flow, catalog).ok).toBe(true); const missing = parseCreationFlow(`{ "steps": [{ "name": "生成规则", "depends_on": [] }] }`)!; - // 编排期允许空壳;必填 params 在确认开干时钉齐 + // 先验产物:编排期允许空壳,确认开干也不拦 expect(validateCreationFlow(missing, catalog).ok).toBe(true); const stepReady = validateStepReadyToRun(missing.steps[0]!, catalog); - expect(stepReady.ok).toBe(false); - expect(stepReady.errors.some((e) => e.includes("target"))).toBe(true); + expect(stepReady.ok).toBe(true); + const missingView = formatCreationFlowForUser(missing, catalog); + expect(missingView.steps[0]?.kind).toBe("prior-artifact"); + expect(missingView.steps[0]?.paramsMissing).toBeUndefined(); const block = formatModuleCatalogForAgent(catalog!); - expect(block).toContain("编排参数"); + expect(block).toContain("规划产物字段"); expect(block).toContain("target"); }); @@ -648,12 +662,47 @@ recipes: acceptedStepNames: [], }, ); - expect(loaded.promptBody).toContain("【本步参数】"); + expect(loaded.promptBody).toContain("【规划产物】"); + expect(loaded.promptBody).not.toContain("勿再问「生成什么"); expect(loaded.promptBody).toContain("target: 怪物"); expect(loaded.promptBody).toContain("rule_id: monsters"); expect(loaded.worker.outputTags).toContain("设计.生成规则"); }); + it("injects empty prior-artifact plan and still lets the step run", async () => { + const catalog = parseModuleCatalog(` +modules: + - name: 生成规则 + declaration: 可执行规则 + artifact: 设计.生成规则 + kind: prior-artifact + params: + - key: target + label: 生成对象 + required: true +`)!; + const empty = parseCreationFlow(`{ + "steps": [{ "name": "生成规则", "depends_on": [] }] + }`)!; + expect(catalog.modules[0]?.kind).toBe("prior-artifact"); + expect(validateStepReadyToRun(empty.steps[0]!, catalog).ok).toBe(true); + + const loaded = await loadWorkerSkillWithContext( + "world-simulator", + "design-step", + undefined, + { + flowRaw: JSON.stringify({ + steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }], + }), + currentStepName: "生成规则", + acceptedStepNames: [], + }, + ); + expect(loaded.promptBody).toContain("【规划产物】"); + expect(loaded.promptBody).toContain("尚未规划本步具体写什么"); + }); + it("nextPendingStep respects deps and accepted by id", () => { const flow = parseCreationFlow(`{ "status": "open", @@ -730,4 +779,135 @@ recipes: }`)!; expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true); }); + + it("allows revising a non-repeatable skill and infers revises + id", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [ + { "name": "实现机制", "depends_on": [] }, + { "name": "实现机制", "mode": "回头修改", "depends_on": ["实现机制"] } + ] + }`)!; + expect(flow.steps[1]).toMatchObject({ + id: "实现机制·改", + name: "实现机制", + mode: "revise", + revises: "实现机制", + }); + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]); + expect(view.steps[1]?.mode).toBe("revise"); + expect(view.steps[1]?.occurrence).toBeUndefined(); + expect(view.steps[1]?.runState).toBe("current"); + }); + + it("still rejects two fresh copies of a non-repeatable skill", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "name": "实现机制", "depends_on": [] }, + { "id": "实现机制#2", "name": "实现机制", "depends_on": ["实现机制"] } + ] + }`)!; + const result = validateCreationFlow(flow, sampleCatalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes("repeatable"))).toBe(true); + }); + + it("treats a second generation-rules step as fresh, not revise", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { "id": "生成规则·怪物", "name": "生成规则", "params": { "target": "怪物" }, "depends_on": [] }, + { "id": "生成规则·NPC", "name": "生成规则", "params": { "target": "NPC" }, "depends_on": ["生成规则·怪物"] } + ] + }`)!; + expect(flow.steps[1]?.mode).toBeUndefined(); + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + }); + + it("revise of generation-rules inherits params from the origin step", () => { + const flow = parseCreationFlow(`{ + "steps": [ + { + "id": "生成规则·怪物", + "name": "生成规则", + "params": { "target": "怪物", "rule_id": "monsters" }, + "depends_on": [] + }, + { + "id": "生成规则·怪物·改", + "name": "生成规则", + "mode": "revise", + "revises": "生成规则·怪物", + "depends_on": ["生成规则·怪物"] + } + ] + }`)!; + expect(flow.steps[1]?.params).toEqual({ + target: "怪物", + rule_id: "monsters", + }); + expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); + }); + + it("nextPendingStep prefers a ready revise over a later fresh step", () => { + const flow = parseCreationFlow(`{ + "status": "open", + "steps": [ + { "name": "美学纲领与交互范式", "depends_on": [] }, + { "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, + { + "name": "美学纲领与交互范式", + "mode": "revise", + "revises": "美学纲领与交互范式", + "depends_on": ["美学纲领与交互范式"] + } + ] + }`)!; + expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise"); + expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe( + "美学纲领与交互范式·改", + ); + }); + + it("injects inherit draft and skips opening on a revise design-step", async () => { + const flowRaw = JSON.stringify({ + status: "open", + steps: [ + { id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] }, + { + id: "美学纲领与交互范式·改", + name: "美学纲领与交互范式", + mode: "revise", + revises: "美学纲领与交互范式", + depends_on: ["美学纲领与交互范式"], + }, + ], + }); + const binding = await resolveDesignStepBinding({ + skillPackRoot: "dialogue/world-simulator", + flowRaw, + currentStepName: "美学纲领与交互范式·改", + acceptedStepNames: ["美学纲领与交互范式"], + }); + expect(binding?.opening).toBeNull(); + expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式"); + + const loaded = await loadWorkerSkillWithContext( + "world-simulator", + "design-step", + undefined, + { + flowRaw, + currentStepName: "美学纲领与交互范式·改", + acceptedStepNames: ["美学纲领与交互范式"], + }, + ); + expect(loaded.promptBody).toContain("【回头修改】"); + expect(loaded.promptBody).not.toContain("程序开场"); + expect(loaded.worker.name).toContain("回头修改"); + expect(loaded.worker.inputTags).toContain("设计.美学纲领与交互范式"); + expect( + loaded.worker.contextSegments?.some((s) => s.id === "inherit-existing"), + ).toBe(true); + }); }); diff --git a/tests/declared-worker.test.ts b/tests/declared-worker.test.ts index 02ffd81..cea2732 100644 --- a/tests/declared-worker.test.ts +++ b/tests/declared-worker.test.ts @@ -17,7 +17,7 @@ describe("resolveAcceptanceModeForWorker", () => { expect(mode).toBe("user_confirmed"); }); - it("maps review → user_confirmed and continue → no_confirmation", () => { + it("maps review → user_confirmed and continue → no_confirmation in design", () => { const bb = new Blackboard(); const yaml = JSON.stringify({ version: 1, @@ -32,7 +32,7 @@ describe("resolveAcceptanceModeForWorker", () => { slots: { ...createSession().slots, designInstanceReady: true, - uiLifecycleStage: "play", + uiLifecycleStage: "design", }, }; @@ -51,6 +51,34 @@ describe("resolveAcceptanceModeForWorker", () => { }), ).toBe("user_confirmed"); }); + + it("play ignores worker-set review and auto-continues", () => { + const bb = new Blackboard(); + bb.write({ + tag: "设计.worker集", + content: JSON.stringify({ + version: 1, + workers: [{ ref: "narrator", acceptance: "review" }], + }), + source: "test", + }); + const session = { + ...createSession(), + slots: { + ...createSession().slots, + designInstanceReady: true, + uiLifecycleStage: "play", + playLayerActive: true, + }, + }; + expect( + resolveAcceptanceModeForWorker({ + session, + blackboard: bb, + workerId: "narrator", + }), + ).toBe("no_confirmation"); + }); }); describe("buildDeclaredWorkerSkill", () => { @@ -92,5 +120,6 @@ describe("buildDeclaredWorkerSkill", () => { expect(promptBody).toContain("残酷但不虐主"); expect(promptBody).toContain("主角免疫"); expect(promptBody).toContain("声明驱动"); + expect(promptBody).toContain("500~2000"); }); }); diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts index 96b3131..f560275 100644 --- a/tests/display-labels.test.ts +++ b/tests/display-labels.test.ts @@ -45,5 +45,11 @@ describe("display-labels", () => { const opening = reviewComposerCopy("opening-generator"); expect(opening.acceptLabel).toBe("选定此开场"); expect(opening.kicker).toBe("待选定"); + + const setup = reviewComposerCopy("design-step", { + outputTags: ["设计.开场白与开场变量"], + }); + expect(setup.acceptLabel).toBe("选定此开场"); + expect(setup.kind).toBe("opening"); }); }); diff --git a/tests/driver.test.ts b/tests/driver.test.ts new file mode 100644 index 0000000..db8fcdb --- /dev/null +++ b/tests/driver.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + createMockToolCall, + MockLlmProvider, +} from "../src/llm/client.js"; +import { createLocalLlmDriver } from "../src/runtime/driver.js"; + +describe("local LLM driver", () => { + it("continues while tools return results, then stops on text", async () => { + const llm = new MockLlmProvider([ + createMockToolCall("lookup", { q: "a" }, "c1"), + "done", + ]); + const driver = createLocalLlmDriver(llm); + const seen: string[] = []; + + const run = await driver.run({ + system: "sys", + messages: [{ role: "user", content: "go" }], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "lookup", + parameters: { type: "object", properties: {} }, + }, + }, + ], + handleStep: (calls) => { + seen.push(...calls.map((c) => c.name)); + return { + kind: "continue", + results: calls.map((c) => ({ callId: c.id, content: "ok" })), + }; + }, + }); + + expect(seen).toEqual(["lookup"]); + expect(run.iterations).toBe(2); + expect(run.stop).toEqual({ kind: "text", content: "done" }); + }); + + it("stops when handleStep concludes, without another model call", async () => { + const llm = new MockLlmProvider([ + createMockToolCall("run_worker", { workerId: "x" }, "t1"), + "should-not-be-used", + ]); + const driver = createLocalLlmDriver(llm); + + const run = await driver.run({ + system: "sys", + messages: [{ role: "user", content: "go" }], + tools: [ + { + type: "function", + function: { + name: "run_worker", + description: "run", + parameters: { type: "object", properties: {} }, + }, + }, + ], + handleStep: (calls) => ({ kind: "conclude", call: calls[0]! }), + }); + + expect(run.iterations).toBe(1); + expect(run.stop.kind).toBe("conclude"); + if (run.stop.kind === "conclude") { + expect(run.stop.call.name).toBe("run_worker"); + } + }); + + it("throws when the iteration cap is hit", async () => { + const llm = new MockLlmProvider([ + createMockToolCall("ping", {}, "p1"), + ]); + const driver = createLocalLlmDriver(llm); + + await expect( + driver.run({ + system: "sys", + messages: [{ role: "user", content: "go" }], + tools: [ + { + type: "function", + function: { + name: "ping", + description: "ping", + parameters: { type: "object", properties: {} }, + }, + }, + ], + maxIterations: 2, + handleStep: (calls) => ({ + kind: "continue", + results: calls.map((c) => ({ callId: c.id, content: "pong" })), + }), + }), + ).rejects.toThrow(/exceeded 2 iterations/); + }); +}); diff --git a/tests/opening-seal.test.ts b/tests/opening-seal.test.ts new file mode 100644 index 0000000..2ee7ad5 --- /dev/null +++ b/tests/opening-seal.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + closeCreationFlowRaw, + isOpeningSealArtifact, + parseOpeningSealPayload, + openingVariablesToTableDoc, +} from "../src/skills/opening-seal.js"; + +const fragment = JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + brief: "宿舍缺水", + mount: ["narrator"], + 正文: { + 开场白全文: "你在宿舍醒来,瓶里只剩一口水。", + 开场白候选: [ + "你在宿舍醒来,瓶里只剩一口水。", + "门外有人砸门。你还没想好要不要开。", + ], + 开场变量: [{ 名: "口粮", 值: 1, 依据: "开场只剩一口水" }], + }, + 开放问题: [], +}); + +describe("opening-seal", () => { + it("detects opening-setup and opening-generator artifacts", () => { + expect( + isOpeningSealArtifact({ + workerId: "design-step", + outputTags: ["设计.开场白与开场变量"], + }), + ).toBe(true); + expect( + isOpeningSealArtifact({ + workerId: "opening-generator", + outputTags: ["输出.开场白"], + }), + ).toBe(true); + expect( + isOpeningSealArtifact({ + workerId: "design-step", + outputTags: ["设计.美学纲领与交互范式"], + }), + ).toBe(false); + }); + + it("parses 1+ opening candidates and selects by index", () => { + const first = parseOpeningSealPayload(fragment, 0); + expect(first?.candidates).toHaveLength(2); + expect(first?.selectedText).toContain("宿舍醒来"); + const second = parseOpeningSealPayload(fragment, 1); + expect(second?.selectedText).toContain("砸门"); + expect(second?.variables).toEqual([ + { name: "口粮", value: 1, note: "开场只剩一口水" }, + ]); + }); + + it("builds table rows from opening variables", () => { + const payload = parseOpeningSealPayload(fragment, 0); + const doc = openingVariablesToTableDoc(payload!.variables); + expect(doc.rows[0]?.key).toBe("口粮"); + expect(doc.rows[0]?.value).toBe(1); + }); + + it("closes an open creation flow", () => { + const raw = JSON.stringify({ + version: 1, + status: "open", + steps: [{ id: "开场白与开场变量", name: "开场白与开场变量", depends_on: [] }], + }); + const closed = JSON.parse(closeCreationFlowRaw(raw)!); + expect(closed.status).toBe("closed"); + }); +}); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index 360cff6..76d4599 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -209,6 +209,72 @@ describe("phase machine", () => { ]); }); + it("later revision notes accumulate instead of replacing earlier ones", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + outputTags: ["设计.正文组成"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + session = applyEvent(session, { + type: "user_rejected_artifact", + payload: { artifactId: artifact.id, reason: "不如原本的上中下样式" }, + }).session; + + session = { + ...session, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + pendingArtifactId: artifact.id, + }; + + const second = applyEvent(session, { + type: "user_rejected_artifact", + payload: { artifactId: artifact.id, reason: "状态很短,放到上面就行了" }, + }); + + expect(second.session.slots["用户.修订说明"]).toContain("不如原本的上中下样式"); + expect(second.session.slots["用户.修订说明"]).toContain("状态很短,放到上面就行了"); + }); + + it("accepting a design-step artifact clears stale revision notes", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + outputTags: ["设计.监控栏"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + slots: { + "用户.修订说明": "上一步的意见", + revisionInstruction: "上一步的意见", + "用户.worker答复": "监控对象是剩余食物", + }, + }; + + session = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }).session; + + expect(session.slots["用户.修订说明"]).toBeUndefined(); + expect(session.slots.revisionInstruction).toBeUndefined(); + expect(session.slots["用户.worker答复"]).toBeUndefined(); + expect(session.slots["用户.需求"]).toContain("监控对象是剩余食物"); + expect(session.slots["用户.需求"]).toContain("上一步的意见"); + }); + it("failed revision rerun falls back to reviewing the previous artifact", () => { let session = createSession("default"); const artifact = { @@ -281,7 +347,35 @@ describe("phase machine", () => { ]); }); - it("accepting design-step enters next_intent instead of invoking main agent", () => { + it("accepting design-step worker set proposes next step instead of invoking main agent", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + stepId: "细化终稿", + outputTags: ["设计.worker集"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + const accepted = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }); + expect(accepted.session.slots.designInstanceReady).toBe(true); + expect(accepted.session.phase).toBe("running"); + expect(accepted.session.waitingReason).toBeUndefined(); + expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]); + expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe( + false, + ); + }); + + it("accepting design-step proposes next step instead of invoking main agent", () => { let session = createSession("default"); const artifact = createArtifact({ workerId: "design-step", @@ -300,12 +394,22 @@ describe("phase machine", () => { type: "user_accepted_artifact", payload: { artifactId: artifact.id }, }); - expect(accepted.session.waitingReason?.kind).toBe("next_intent"); + expect(accepted.session.phase).toBe("running"); + expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]); expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe( false, ); + }); - const continued = applyEvent(accepted.session, { + it("legacy next_intent still proposes next step and records intent", () => { + let session = createSession("default"); + session = { + ...session, + phase: "waiting_user", + waitingReason: { kind: "next_intent", afterWorkerId: "design-step" }, + }; + + const continued = applyEvent(session, { type: "user_submitted_input", payload: { text: "接下来写怪物生成规则" }, }); @@ -317,4 +421,124 @@ describe("phase machine", () => { { type: "propose_next_creation_step" }, ]); }); + + it("accepting opening-setup seals creation instead of next_intent", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "design-step", + stepId: "开场白与开场变量", + outputTags: ["设计.开场白与开场变量"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + const accepted = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }); + expect(accepted.session.waitingReason?.kind).toBe("input"); + expect(accepted.session.waitingReason).toMatchObject({ + kind: "input", + }); + expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]); + expect( + accepted.effects.some((e) => e.type === "invoke_main_agent"), + ).toBe(false); + }); + + it("accepting opening-generator also seals creation", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "opening-generator", + outputTags: ["输出.开场白", "运行.初始变量"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + }; + + const accepted = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }); + expect(accepted.session.waitingReason?.kind).toBe("input"); + expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]); + }); + + it("sealed creation still blocks input until play", () => { + let session = createSession("default"); + session = { + ...session, + phase: "waiting_user", + waitingReason: { kind: "input" }, + slots: { creationSealedByOpening: true, designInstanceReady: true }, + }; + const blocked = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "我买金首饰" }, + }); + expect(blocked.effects).toEqual([ + { type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" }, + ]); + expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false); + }); + + it("play input starts a play turn even after opening seal", () => { + let session = createSession("default"); + session = { + ...session, + phase: "waiting_user", + waitingReason: { kind: "input" }, + slots: { + creationSealedByOpening: true, + designInstanceReady: true, + uiLifecycleStage: "play", + }, + }; + const play = applyEvent(session, { + type: "user_submitted_input", + payload: { text: "我买金首饰" }, + }); + expect(play.session.phase).toBe("running"); + expect(play.effects).toEqual([{ type: "run_play_turn" }]); + }); + + it("accepting a play worker continues the play pipeline", () => { + let session = createSession("default"); + const artifact = createArtifact({ + workerId: "narrator", + outputTags: ["输出.用户展示"], + }); + session = { + ...session, + artifacts: [artifact], + pendingArtifactId: artifact.id, + phase: "waiting_user", + waitingReason: { kind: "review_artifact", artifactId: artifact.id }, + slots: { + designInstanceReady: true, + uiLifecycleStage: "play", + playLayerActive: true, + }, + }; + const accepted = applyEvent(session, { + type: "user_accepted_artifact", + payload: { artifactId: artifact.id }, + }); + expect(accepted.effects).toEqual([{ type: "continue_play_turn" }]); + expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe( + false, + ); + expect(accepted.effects.some((e) => e.type === "propose_next_creation_step")).toBe( + false, + ); + }); }); diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index f063a51..c68c27d 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -5,6 +5,8 @@ import { MockLlmProvider, } from "../src/llm/client.js"; import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js"; +import { createSession } from "../src/runtime/phase-machine.js"; +import { Blackboard } from "../src/blackboard/blackboard.js"; describe("phase runtime", () => { it("auto-loads default orchestrator and awaits first user input", async () => { @@ -47,4 +49,82 @@ describe("phase runtime", () => { true, ); }); + + it("play turn runs declared pipeline without review and waits for input", async () => { + const session = createSession("default"); + session.phase = "waiting_user"; + session.waitingReason = { kind: "input" }; + session.slots = { + designInstanceReady: true, + uiLifecycleStage: "play", + playLayerActive: true, + startupCompleted: true, + }; + const blackboard = new Blackboard(); + blackboard.write({ + tag: "设计.worker集", + content: JSON.stringify({ + version: 1, + play_slots: { + auditor: false, + gm: true, + narrator: true, + perspective: false, + }, + workers: [ + { ref: "world-simulator", acceptance: "continue" }, + { ref: "narrator", acceptance: "review" }, + ], + }), + source: "test", + }); + const runtime = new PhaseRuntime({ + autoStubWorker: true, + initialSession: session, + initialBlackboardItems: blackboard.exportItems(), + }); + await runtime.submitInput("我网购一个金首饰试试"); + expect(runtime.getSession().waitingReason?.kind).toBe("input"); + expect(runtime.getSession().artifacts.map((a) => a.workerId)).toEqual([ + "world-simulator", + "narrator", + ]); + expect(runtime.getSession().artifacts.every((a) => a.status === "accepted")).toBe( + true, + ); + }); + + it("retryStuckRun restores the in-flight worker snapshot and reruns", 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 artifactsBefore = runtime.getSession().artifacts.length; + + const session = runtime.getSession(); + session.phase = "running"; + session.waitingReason = undefined; + session.currentWorkerId = "design-flow"; + + await runtime.retryStuckRun(); + expect(runtime.getSession().artifacts.length).toBeGreaterThan(artifactsBefore); + expect( + runtime.getSession().artifacts.filter((a) => a.workerId === "design-flow").length, + ).toBeGreaterThanOrEqual(2); + }); + + it("retryStuckRun throws when nothing is running", async () => { + const runtime = new PhaseRuntime({ autoStubWorker: true }); + await runtime.start(); + await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行"); + }); }); diff --git a/tests/play-frame.test.ts b/tests/play-frame.test.ts new file mode 100644 index 0000000..c0e7736 --- /dev/null +++ b/tests/play-frame.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { importSillyTavernPreset } from "../src/preset/importer.js"; +import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js"; +import { assemblePresetMessages } from "../src/preset/assembler.js"; +import type { WorldInfoPack } from "../src/preset/world-info-pack.js"; + +const pack: WorldInfoPack = { + worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }], + history: "用户:昨天来过", + worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }], + turn: "我推门", + postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }], +}; + +describe("assemblePlayWorkerMessages", () => { + it("walks extended prompt_order: ST pre, our before, history, our after, turn, ST post, postTurn", () => { + const report = importSillyTavernPreset({ + prompts: [ + { identifier: "main", name: "Main", role: "system", content: "MAIN" }, + { + identifier: "charDescription", + name: "Desc", + role: "system", + marker: true, + }, + { + identifier: "chatHistory", + name: "History", + role: "system", + marker: true, + }, + { identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" }, + ], + prompt_order: [ + { + character_id: 1, + order: [ + { identifier: "main", enabled: true }, + { identifier: "charDescription", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "jailbreak", enabled: true }, + ], + }, + ], + }); + + const messages = assemblePlayWorkerMessages({ + systemPrompt: "SKILL", + preset: report.preset, + pack, + }); + + expect(messages.map((m) => [m.role, m.content])).toEqual([ + ["system", "SKILL"], + ["system", "MAIN"], + ["system", "### 舞台\n\n古镇夜雨"], + ["system", "用户:昨天来过"], + ["system", "### 变量\n\nHP 3"], + ["user", "我推门"], + ["system", "PHI"], + ["system", "### 尾注\n\n勿剧透"], + ]); + }); + + it("does not fill ST charDescription even if resolver returns content", () => { + const report = importSillyTavernPreset({ + prompts: [ + { + identifier: "charDescription", + name: "Desc", + role: "system", + marker: true, + }, + { + identifier: "chatHistory", + name: "History", + role: "system", + marker: true, + }, + ], + prompt_order: [ + { + character_id: 1, + order: [ + { identifier: "charDescription", enabled: true }, + { identifier: "chatHistory", enabled: true }, + ], + }, + ], + }); + const messages = assemblePresetMessages(report.preset, (id) => { + if (id === "charDescription") return "CARD"; + if (id === "chatHistory") return "HIST"; + return null; + }); + expect(messages.map((m) => m.content)).toEqual(["HIST"]); + }); +}); diff --git a/tests/play-turn.test.ts b/tests/play-turn.test.ts new file mode 100644 index 0000000..c9ee1ba --- /dev/null +++ b/tests/play-turn.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + SLOT_PLAY_LAYER_ACTIVE, + SLOT_PLAY_TURN_QUEUE, + isPlayLayerActive, + readPlayTurnQueue, + withPlayTurnQueue, +} from "../src/skills/play-turn.js"; + +describe("play-turn slots", () => { + it("reads a trimmed queue and ignores junk", () => { + expect(readPlayTurnQueue(undefined)).toEqual([]); + expect( + readPlayTurnQueue({ [SLOT_PLAY_TURN_QUEUE]: [" auditor ", "", 3, "gm"] }), + ).toEqual(["auditor", "gm"]); + }); + + it("writes queue or clears when empty", () => { + const withIds = withPlayTurnQueue({ a: 1 }, ["gm", "narrator"]); + expect(withIds[SLOT_PLAY_TURN_QUEUE]).toEqual(["gm", "narrator"]); + expect(withPlayTurnQueue(withIds, []).playTurnQueue).toBeUndefined(); + }); + + it("detects play layer flag", () => { + expect(isPlayLayerActive(undefined)).toBe(false); + expect(isPlayLayerActive({ [SLOT_PLAY_LAYER_ACTIVE]: true })).toBe(true); + }); +}); diff --git a/tests/present-packet.test.ts b/tests/present-packet.test.ts index ba7f43a..439a74a 100644 --- a/tests/present-packet.test.ts +++ b/tests/present-packet.test.ts @@ -51,6 +51,31 @@ describe("present.v1", () => { expect(p.shell).toBe("chapter_reader"); expect(p.blocks.body).toBe("hello"); }); + + it("strips 输出.用户展示 heading from plain body", () => { + const view = parsePresentPacket("### 输出.用户展示\n\n叮!账户来了。"); + expect(view.packet.blocks.body).toBe("叮!账户来了。"); + expect(String(view.packet.blocks.body)).not.toContain("用户展示"); + }); + + it("strips heading then parses present JSON", () => { + const view = parsePresentPacket( + '### 输出.用户展示\n\n{"schema":"present.v1","shell":"prose","blocks":{"body":"场面很长"}}', + ); + expect(view.fallbackPlain).toBe(false); + expect(view.packet.blocks.body).toBe("场面很长"); + }); + + it("strips heading inside blocks.body", () => { + const view = parsePresentPacket( + JSON.stringify({ + schema: "present.v1", + shell: "prose", + blocks: { body: "### 输出.用户展示\n正文" }, + }), + ); + expect(view.packet.blocks.body).toBe("正文"); + }); }); describe("shell adaptation from reply-format", () => { diff --git a/tests/preset-probe.test.ts b/tests/preset-probe.test.ts new file mode 100644 index 0000000..4bd97f4 --- /dev/null +++ b/tests/preset-probe.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { importSillyTavernPreset } from "../src/preset/importer.js"; +import { + assemblePresetProbe, + DEFAULT_PROBE_CONTEXT, + PROBE_SYSTEM_PROMPT, + runPresetProbe, +} from "../src/preset/probe.js"; + +function samplePreset() { + return importSillyTavernPreset({ + prompts: [ + { identifier: "main", name: "Main", role: "system", content: "MAIN" }, + { + identifier: "charDescription", + name: "Desc", + role: "system", + marker: true, + }, + { + identifier: "chatHistory", + name: "History", + role: "system", + marker: true, + }, + { identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" }, + ], + prompt_order: [ + { + character_id: 1, + order: [ + { identifier: "main", enabled: true }, + { identifier: "charDescription", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "jailbreak", enabled: true }, + ], + }, + ], + }).preset; +} + +describe("assemblePresetProbe", () => { + it("fills the sandwich with sample lore, history, and the current turn", () => { + const messages = assemblePresetProbe(samplePreset(), { + message: "我推开门。", + }); + + expect(messages.map((m) => [m.role, m.content])).toEqual([ + ["system", PROBE_SYSTEM_PROMPT], + ["system", "MAIN"], + ["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`], + ["system", DEFAULT_PROBE_CONTEXT.history], + ["system", `### 本轮状态\n\n${DEFAULT_PROBE_CONTEXT.loreAfter}`], + ["user", "我推开门。"], + ["system", "PHI"], + ]); + }); + + it("omits empty optional bags", () => { + const messages = assemblePresetProbe(samplePreset(), { + message: "你好", + loreBefore: "", + history: "", + loreAfter: "", + postTurn: "", + }); + + expect(messages.map((m) => m.content)).toEqual([ + PROBE_SYSTEM_PROMPT, + "MAIN", + "你好", + "PHI", + ]); + }); +}); + +describe("runPresetProbe", () => { + it("returns assembled messages without calling a model", async () => { + const result = await runPresetProbe({ + preset: samplePreset(), + input: { message: "我推开门。" }, + }); + + expect(result.completed).toBe(false); + expect(result.reply).toBeNull(); + expect(result.messages.some((m) => m.content === "我推开门。")).toBe(true); + }); + + it("returns the model reply when complete is provided", async () => { + const result = await runPresetProbe({ + preset: samplePreset(), + input: { message: "我推开门。" }, + complete: async () => ({ content: "请进。", reasoning: "短回复" }), + }); + + expect(result.completed).toBe(true); + expect(result.reply).toBe("请进。"); + expect(result.reasoning).toBe("短回复"); + }); + + it("keeps messages when generate fails", async () => { + const result = await runPresetProbe({ + preset: samplePreset(), + input: { message: "我推开门。" }, + complete: async () => { + throw new Error("upstream down"); + }, + }); + + expect(result.completed).toBe(false); + expect(result.error).toBe("upstream down"); + expect(result.messages.length).toBeGreaterThan(0); + }); + + it("rejects an empty turn", async () => { + await expect( + runPresetProbe({ + preset: samplePreset(), + input: { message: " " }, + }), + ).rejects.toThrow("缺少本轮输入"); + }); +}); diff --git a/tests/preset-wrapper.test.ts b/tests/preset-wrapper.test.ts new file mode 100644 index 0000000..06eee66 --- /dev/null +++ b/tests/preset-wrapper.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { + ChatMessage, + CompleteResult, + LlmProvider, +} from "../src/llm/client.js"; +import { wrapLlmForSession } from "../src/llm/preset-wrapper.js"; +import type { LlmTrackingContext } from "../src/llm/token-tracker.js"; +import type { PresetPackage } from "../src/types/preset.js"; + +const samplePreset: PresetPackage = { + id: "p1", + name: "test-preset", + source: "native", + prompts: [ + { + id: "sys", + name: "系统", + enabled: true, + role: "system", + content: "PRESET_RULES", + marker: false, + sourceIdentifier: "sys", + }, + ], + promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }], + generation: { temperature: 0.91, maxOutputTokens: 1234 }, + unsupported: [], + importedAt: "2026-01-01T00:00:00.000Z", +}; + +function recordingProvider(): { + provider: LlmProvider; + received: Array<{ messages: ChatMessage[]; generation?: unknown }>; +} { + const received: Array<{ messages: ChatMessage[]; generation?: unknown }> = []; + const provider: LlmProvider = { + async complete(messages, options): Promise { + received.push({ messages, generation: options?.generation }); + return { content: "ok", model: "rec-model" }; + }, + async completeStream(messages, options, callbacks) { + received.push({ messages, generation: options?.generation }); + callbacks?.onContentDelta?.("ok"); + return { content: "ok", model: "rec-model" }; + }, + async completeWithTools(messages, options) { + received.push({ messages, generation: options.generation }); + return { content: "ok", toolCalls: [], model: "rec-model" }; + }, + }; + return { provider, received }; +} + +describe("wrapLlmForSession", () => { + it("context trace matches the exact messages and generation sent to the inner LLM", async () => { + const { provider, received } = recordingProvider(); + const ctx: LlmTrackingContext = {}; + const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset); + + await llm.complete( + [{ role: "user", content: "TASK" }], + { caller: "worker:design-step" }, + ); + + expect(received).toHaveLength(1); + expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]); + expect(received[0].generation).toMatchObject({ + temperature: 0.91, + maxOutputTokens: 1234, + }); + expect(ctx.pendingContextTrace?.messages).toEqual([ + { role: "user", content: "TASK" }, + ]); + expect(ctx.pendingContextTrace?.generation).toMatchObject({ + temperature: 0.91, + maxOutputTokens: 1234, + }); + expect(ctx.pendingContextTrace?.caller).toBe("worker:design-step"); + expect(ctx.pendingContextTrace?.model).toBe("rec-model"); + }); + + it("completeStream also records the merged request", async () => { + const { provider, received } = recordingProvider(); + const ctx: LlmTrackingContext = {}; + const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset); + const chunks: string[] = []; + + await llm.completeStream!( + [{ role: "user", content: "TASK" }], + { caller: "worker:stream" }, + { onContentDelta: (d) => chunks.push(d) }, + ); + + expect(chunks.join("")).toBe("ok"); + expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]); + expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages); + }); +}); diff --git a/tests/preset.test.ts b/tests/preset.test.ts index bd437a3..250f5f7 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -52,16 +52,73 @@ const samplePreset = { describe("importSillyTavernPreset", () => { it("picks prompt_order with most enabled content", () => { const report = importSillyTavernPreset(samplePreset, { name: "test" }); - expect(report.enabledCount).toBe(3); expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([ "custom-a", "custom-b", "main", + "worldBookBefore", + "chatHistory", + "worldBookAfter", + "currentTurn", + "postTurn", ]); + expect(report.enabledCount).toBe(8); expect(report.preset.generation.temperature).toBe(1); expect(report.preset.generation.maxOutputTokens).toBe(60000); expect(report.warnings.some((w) => w.includes("prompt_order"))).toBe(true); }); + + it("inserts app markers around chatHistory and leaves ST card/WI empty", () => { + const report = importSillyTavernPreset({ + prompts: [ + { identifier: "main", name: "Main", role: "system", content: "MAIN" }, + { + identifier: "charDescription", + name: "Char Description", + role: "system", + marker: true, + }, + { + identifier: "chatHistory", + name: "Chat History", + role: "system", + marker: true, + }, + { + identifier: "jailbreak", + name: "Post-History", + role: "system", + content: "PHI", + }, + ], + prompt_order: [ + { + character_id: 1, + order: [ + { identifier: "main", enabled: true }, + { identifier: "charDescription", enabled: true }, + { identifier: "chatHistory", enabled: true }, + { identifier: "jailbreak", enabled: true }, + ], + }, + ], + }); + expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([ + "main", + "charDescription", + "worldBookBefore", + "chatHistory", + "worldBookAfter", + "currentTurn", + "jailbreak", + "postTurn", + ]); + const messages = assemblePresetMessages(report.preset); + expect(messages).toEqual([ + { role: "system", content: "MAIN" }, + { role: "system", content: "PHI" }, + ]); + }); }); describe("assemblePresetMessages", () => { @@ -79,7 +136,7 @@ describe("listEnabledPresetEntries", () => { it("lists enabled order with inject flags", () => { const report = importSillyTavernPreset(samplePreset); const entries = listEnabledPresetEntries(report.preset); - expect(entries.length).toBe(3); + expect(entries.length).toBe(8); expect(countInjectingEntries(entries)).toBe(2); expect(entries.filter((e) => e.willInject).map((e) => e.content)).toEqual([ "instruction A", diff --git a/tests/run-abort.test.ts b/tests/run-abort.test.ts new file mode 100644 index 0000000..b77c728 --- /dev/null +++ b/tests/run-abort.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { MockLlmProvider } from "../src/llm/client.js"; +import { + isAbortError, + runWithAbortSignal, + throwIfAborted, +} from "../src/llm/run-abort.js"; + +describe("run abort", () => { + it("isAbortError detects AbortError name", () => { + const err = new Error("This operation was aborted"); + err.name = "AbortError"; + expect(isAbortError(err)).toBe(true); + expect(isAbortError(new Error("LLM request failed"))).toBe(false); + }); + + it("throwIfAborted raises AbortError when the signal is aborted", () => { + const ac = new AbortController(); + ac.abort(); + expect(() => throwIfAborted(ac.signal)).toThrowError(/aborted/i); + }); + + it("mock LLM respects the active abort signal", async () => { + const llm = new MockLlmProvider(["ok"]); + const ac = new AbortController(); + ac.abort(); + await expect( + runWithAbortSignal(ac.signal, () => llm.complete([{ role: "user", content: "x" }])), + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/tests/snapshot-filters.test.ts b/tests/snapshot-filters.test.ts index bf13681..9fc194c 100644 --- a/tests/snapshot-filters.test.ts +++ b/tests/snapshot-filters.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - filterBlackboardForInstance, isRunPhaseBlackboardTag, materializeInstanceSnapshotPayload, } from "../src/book/snapshot-filters.js"; @@ -12,6 +11,10 @@ describe("snapshot-filters", () => { expect(isRunPhaseBlackboardTag("角色.A.设定")).toBe(false); expect(isRunPhaseBlackboardTag("博弈.规则.草稿")).toBe(false); expect(isRunPhaseBlackboardTag("运行.事件流")).toBe(true); + expect(isRunPhaseBlackboardTag("输出.开场白")).toBe(false); + expect(isRunPhaseBlackboardTag("运行.初始变量")).toBe(false); + expect(isRunPhaseBlackboardTag("变量.当前")).toBe(false); + expect(isRunPhaseBlackboardTag("对话.历史")).toBe(true); expect(isRunPhaseBlackboardTag("角色.A.思考")).toBe(true); expect(isRunPhaseBlackboardTag("世界.当前状态")).toBe(true); }); @@ -19,6 +22,8 @@ describe("snapshot-filters", () => { it("materializeInstanceSnapshotPayload removes run blackboard items", () => { const session = createSession("default"); session.slots.startupCompleted = true; + session.slots.playLayerActive = true; + session.slots.playTurnQueue = ["narrator"]; session.artifacts = [ { id: "a1", @@ -44,6 +49,7 @@ describe("snapshot-filters", () => { { id: "1", tag: "情境.实验.设定", content: "x", source: "worker", createdAt: "", updatedAt: "" }, { id: "2", tag: "运行.事件流", content: "y", source: "worker", createdAt: "", updatedAt: "" }, { id: "3", tag: "角色.A.设定", content: "z", source: "worker", createdAt: "", updatedAt: "" }, + { id: "4", tag: "对话.历史", content: "## 用户\n\n买金", source: "runtime", createdAt: "", updatedAt: "" }, ], }); @@ -53,6 +59,7 @@ describe("snapshot-filters", () => { ]); expect(runtimeSession.artifacts).toHaveLength(1); expect(runtimeSession.artifacts[0].workerId).toBe("setup-scenario"); - expect(filterBlackboardForInstance).toBeDefined(); + expect(runtimeSession.slots.playLayerActive).toBeUndefined(); + expect(runtimeSession.slots.playTurnQueue).toBeUndefined(); }); }); diff --git a/tests/worker-declaration.test.ts b/tests/worker-declaration.test.ts index 3b9c068..8d8a4dc 100644 --- a/tests/worker-declaration.test.ts +++ b/tests/worker-declaration.test.ts @@ -98,5 +98,14 @@ describe("worker-declaration", () => { }, }; expect(inferLifecycleStage(s2)).toBe("play"); + + const s3 = { + ...sessionWithAcceptedWorkerSet(), + slots: { + ...sessionWithAcceptedWorkerSet().slots, + playLayerActive: true, + }, + }; + expect(inferLifecycleStage(s3)).toBe("play"); }); }); diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts index aa01a7e..21fcaca 100644 --- a/tests/worker-set-sanitize.test.ts +++ b/tests/worker-set-sanitize.test.ts @@ -191,7 +191,7 @@ describe("sanitizeWorkerSetOutputs", () => { expect(result.outputs["设计.美学纲领与交互范式"]).toContain("特权与惊惶"); }); - it("does not accept a fragment without 正文; keeps 追问 for the LLM loop", () => { + it("keeps incomplete fragment (no 正文) for review; 追问 hangs as sidecar", () => { const result = parseWorkerResponseForTest( JSON.stringify({ outputs: { @@ -210,12 +210,12 @@ describe("sanitizeWorkerSetOutputs", () => { }), ["设计.美学纲领与交互范式"], ); - expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined(); + expect(result.outputs["设计.美学纲领与交互范式"]).toContain("还差站位"); expect(result.askUser?.length).toBeGreaterThan(0); expect(result.askUser?.[0]?.prompt).toContain("你代入吗"); }); - it("asks to continue the loop when fragment JSON is unusable", () => { + it("keeps truncated fragment JSON as a reviewable draft", () => { const result = parseWorkerResponseForTest( JSON.stringify({ outputs: { @@ -226,7 +226,54 @@ describe("sanitizeWorkerSetOutputs", () => { }), ["设计.美学纲领与交互范式"], ); - expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined(); - expect(result.askUser?.[0]?.prompt).toContain("还没写出可验收的产物"); + expect(result.outputs["设计.美学纲领与交互范式"]).toContain("孤立免疫"); + expect(result.askUser).toBeUndefined(); + }); + + it("canonicalizes context-order agents[] into slots on the artifact tag", () => { + const result = parseWorkerResponseForTest( + JSON.stringify({ + outputs: { + "设计.上下文投影排序": { + schema: "context-order.v1", + brief: "主世界层", + agents: [ + { + id: "gm", + enabled: true, + inserts: [ + { order: 0, ref: "worker.persona", projection: "fixed" }, + ], + }, + ], + }, + }, + summary: "ok", + }), + ["设计.上下文投影排序"], + ); + const doc = JSON.parse(result.outputs["设计.上下文投影排序"]!); + expect(doc.slots[0].ref).toBe("world-simulator"); + expect(doc.play_slots.gm).toBe(true); + }); + + it("keeps a whole-response truncated JSON as the artifact draft", () => { + const raw = + '{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":['; + const result = parseWorkerResponseForTest(raw, ["设计.上下文投影排序"]); + expect(result.outputs["设计.上下文投影排序"]).toContain("context-order.v1"); + expect(result.askUser).toBeUndefined(); + }); + + it("puts full 输出.用户展示 into preview without tag fence or 4000 cap", () => { + const body = "场面".repeat(2100); + const result = sanitizeWorkerSetOutputs({ + outputs: { "输出.用户展示": body }, + summary: "ok", + preview: "旧", + }); + expect(result.preview).toBe(body); + expect(result.preview).not.toContain("### "); + expect(result.preview.length).toBeGreaterThan(4000); }); }); diff --git a/tests/world-info-pack.test.ts b/tests/world-info-pack.test.ts new file mode 100644 index 0000000..add791f --- /dev/null +++ b/tests/world-info-pack.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { DIALOGUE_HISTORY_TAG } from "../src/skills/dialogue-history.js"; +import { + CURRENT_TURN_TAG, + resolveWorldInfoMarker, + worldInfoPackFromSegments, +} from "../src/preset/world-info-pack.js"; +import { + CURRENT_TURN, + WORLD_BOOK_AFTER, + WORLD_BOOK_BEFORE, +} from "../src/preset/markers.js"; +import type { ContextSegmentDef } from "../src/skills/context-segments.js"; + +describe("worldInfoPackFromSegments", () => { + it("splits lore / history / after-history / turn by the two anchors", () => { + const bb = new Blackboard(); + const segments: ContextSegmentDef[] = [ + { + id: "stage", + tier: "static", + tags: [], + inline: "古镇夜雨", + label: "## 舞台骨架", + }, + { + id: "hist", + tier: "dynamic", + tags: [DIALOGUE_HISTORY_TAG], + }, + { + id: "vars", + tier: "dynamic", + tags: ["变量.当前"], + label: "## 变量", + }, + { + id: "turn", + tier: "dynamic", + tags: [CURRENT_TURN_TAG], + }, + { + id: "note", + tier: "dynamic", + tags: [], + inline: "尾注", + label: "## 本轮后", + }, + ]; + const pack = worldInfoPackFromSegments({ + segments, + inputs: { + [DIALOGUE_HISTORY_TAG]: "用户:你好", + "变量.当前": "HP 3", + [CURRENT_TURN_TAG]: "我推门进去", + }, + blackboard: bb, + }); + expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]); + expect(pack.history).toContain("你好"); + expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]); + expect(pack.turn).toBe("我推门进去"); + expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]); + expect(resolveWorldInfoMarker(pack, WORLD_BOOK_BEFORE)).toContain("古镇夜雨"); + expect(resolveWorldInfoMarker(pack, WORLD_BOOK_AFTER)).toContain("HP 3"); + expect(resolveWorldInfoMarker(pack, CURRENT_TURN)).toBe("我推门进去"); + expect(resolveWorldInfoMarker(pack, "charDescription")).toBeNull(); + }); +}); diff --git a/web/agent-ui.js b/web/agent-ui.js index 2a03036..0281d12 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -12,6 +12,7 @@ import { parsePresentDoc, presentFromPlain, renderPresentShellHtml, + stripPresentSourceFences, } from "./present-shells.js"; const HIDE_KINDS = new Set(["worker_stub"]); @@ -57,6 +58,10 @@ function esc(s) { .replace(/>/g, ">"); } +function escAttr(s) { + return esc(s).replace(/"/g, """); +} + function renderThinkingBlock(thinking, { open = false } = {}) { const text = (thinking ?? "").trim(); if (!text) return ""; @@ -66,23 +71,39 @@ function renderThinkingBlock(thinking, { open = false } = {}) { `; } +function isPlayView(view) { + return Boolean(view?.playLayerActive || view?.lifecycleStage === "play"); +} + function renderLiveStreamBody(live, view) { const thinking = (live?.thinking || view?.agentThinking || "").trim(); const output = (live?.output ?? "").trim(); - const showOutput = - Boolean(output) && (view?.lifecycleStage ?? document.body.dataset.lifecycle) === "play"; const parts = []; if (thinking) { parts.push( `
思考
${esc(thinking)}
`, ); } - if (showOutput) { + if (output) { parts.push( `
输出
${esc(output)}
`, ); } - return parts.length ? parts.join("") : "…"; + return parts.length + ? parts.join("") + : `

正在生成,尚未收到内容

`; +} + +function renderRetryRunButton() { + return ``; +} + +function wireRetryRun(root, handlers) { + root?.querySelector("[data-act=retry_run]")?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + handlers.onRetryRun?.(); + }); } function fmtTime(iso) { @@ -141,13 +162,19 @@ function hideMsgMenu() { const menu = document.getElementById("msg-action-menu"); if (menu) menu.hidden = true; msgMenuState.messageId = null; + msgMenuState.contextMessageId = null; msgMenuState.rollbackMessageId = null; + msgMenuState.rollbackMode = "keep"; + msgMenuState.restoreDraft = ""; msgMenuState.card = null; } const msgMenuState = { messageId: null, + contextMessageId: null, rollbackMessageId: null, + rollbackMode: "keep", + restoreDraft: "", card: null, handlers: null, }; @@ -155,25 +182,38 @@ const msgMenuState = { function showMsgMenu(card, x, y) { const menu = document.getElementById("msg-action-menu"); if (!menu || !card) return; - const isReview = card.dataset.review === "1"; - const isReadOnly = card.dataset.readOnly === "1"; - const canEdit = !isReview && card.dataset.canEdit === "1"; - const canDelete = !isReview && !isReadOnly && Boolean(card.dataset.messageId); - const canRollback = !isReview && !isReadOnly && Boolean(card.dataset.rollbackMessageId); - const hasContext = card.dataset.hasContext === "1"; + const pack = card.closest(".coord-pack"); + const rollbackHost = pack || card; + // 上下文按单次请求(这条消息)区分,不能回落到整包里的另一条 + const contextTarget = card.dataset.hasContext === "1" ? card : null; + const isReview = card.dataset.review === "1" || pack?.dataset.review === "1"; + const isReadOnly = card.dataset.readOnly === "1" || pack?.dataset.readOnly === "1"; + const canEdit = !isReview && !pack && card.dataset.canEdit === "1"; + const canDelete = !isReview && !isReadOnly && !pack && Boolean(card.dataset.messageId); + const rollbackMessageId = rollbackHost.dataset.rollbackMessageId ?? null; + const rollbackMode = rollbackHost.dataset.rollbackMode || "keep"; + const canRollback = !isReview && !isReadOnly && Boolean(rollbackMessageId); + const hasContext = Boolean(contextTarget); const editBtn = document.getElementById("msg-menu-edit"); const delBtn = document.getElementById("msg-menu-delete"); const rollbackBtn = document.getElementById("msg-menu-rollback"); const ctxBtn = document.getElementById("msg-menu-context"); if (editBtn) editBtn.toggleAttribute("hidden", !canEdit); if (delBtn) delBtn.toggleAttribute("hidden", !canDelete); - if (rollbackBtn) rollbackBtn.toggleAttribute("hidden", !canRollback); + if (rollbackBtn) { + rollbackBtn.toggleAttribute("hidden", !canRollback); + rollbackBtn.textContent = + rollbackMode === "pending-input" ? "回到这一轮输入" : "回退到这里"; + } if (ctxBtn) ctxBtn.toggleAttribute("hidden", !hasContext); menu.hidden = false; menu.style.left = `${x}px`; menu.style.top = `${y}px`; msgMenuState.messageId = card.dataset.messageId ?? null; - msgMenuState.rollbackMessageId = card.dataset.rollbackMessageId ?? null; + msgMenuState.contextMessageId = contextTarget?.dataset.messageId ?? card.dataset.messageId ?? null; + msgMenuState.rollbackMessageId = rollbackMessageId; + msgMenuState.rollbackMode = rollbackMode; + msgMenuState.restoreDraft = rollbackHost.dataset.restoreDraft ?? ""; msgMenuState.card = card; const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) { @@ -193,12 +233,15 @@ function wireMsgActionMenu(handlers) { menu.addEventListener("click", async (e) => { const action = e.target.closest("[data-msg-menu-action]")?.getAttribute("data-msg-menu-action"); const messageId = msgMenuState.messageId; + const contextMessageId = msgMenuState.contextMessageId || messageId; const rollbackMessageId = msgMenuState.rollbackMessageId; + const rollbackMode = msgMenuState.rollbackMode; + const restoreDraft = msgMenuState.restoreDraft; const card = msgMenuState.card; hideMsgMenu(); if (!messageId || !action) return; const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing), .coord-text"); - const body = bodyEl?.textContent ?? card?.dataset.originalText ?? ""; + const body = card?.dataset.originalText || bodyEl?.textContent || ""; if (action === "copy") { try { @@ -209,7 +252,7 @@ function wireMsgActionMenu(handlers) { return; } if (action === "view-context") { - msgMenuState.handlers?.onViewContext?.(messageId); + msgMenuState.handlers?.onViewContext?.(contextMessageId); return; } if (action === "edit") { @@ -218,8 +261,20 @@ function wireMsgActionMenu(handlers) { } if (action === "rollback") { if (!rollbackMessageId) return; - if (!confirm("回退到这条消息?之后的对话和创作状态将被移除。")) return; - msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId); + const pendingInput = rollbackMode === "pending-input"; + const confirmed = pendingInput + ? confirm( + "回到这一轮的待输入?这一轮及之后的对话和创作状态将被移除,原文会填回底栏。", + ) + : confirm("回退到这条消息?之后的对话和创作状态将被移除。"); + if (!confirmed) return; + if (pendingInput) { + const restore = msgMenuState.handlers?.onRollbackToInput; + if (typeof restore === "function") restore(rollbackMessageId, restoreDraft); + else msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId); + } else { + msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId); + } return; } if (action === "delete") { @@ -234,7 +289,7 @@ function wireMessageContextMenu(feed, handlers) { feed.dataset.contextWired = "1"; wireMsgActionMenu(handlers); feed.addEventListener("contextmenu", (e) => { - const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line"); + const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line, .coord-pack"); const card = e.target.closest("[data-message-id]"); if (!bubble || !card || card.classList.contains("msg-pending")) return; e.preventDefault(); @@ -423,22 +478,40 @@ function formatQuestionsHtml(body) { } /** play 期助手终稿(含已验收的用户展示)用呈现壳渲染 */ +function isPlayHousekeepingMessage(msg) { + const title = String(msg.title ?? ""); + const text = String(msg.text ?? msg.body ?? "").trim(); + if (title === "上下文已压缩" || title === "阶段机") return true; + if (/^\[上下文已压缩\]/.test(text)) return true; + if (/^\[裁决合并\]/.test(text)) return true; + if (/^\[旁观维护\]/.test(text)) return true; + if (/归档\s*\d+\s*个过程 tag/.test(text)) return true; + return false; +} + function shouldRenderPlayPresent(msg, view) { - if (view?.lifecycleStage !== "play") return false; + if (!isPlayView(view)) return false; if (msg.role === "user") return false; const kind = msg.kind ?? ""; if (kind === "worker_questions" || kind === "orchestrator_thinking") return false; - if (kind === "worker_output") return false; // 已走 formatArtifactBodyHtml + if (isPlayHousekeepingMessage(msg)) return false; const actor = String(msg.actor ?? ""); - if (actor === "narrator" || /用户展示|开场白/.test(String(msg.title ?? ""))) { - return true; - } - // 助手可见终稿:无特殊 kind 的长文 - return kind === "system_info" || kind === "worker_stub" || !kind; + const title = String(msg.title ?? ""); + return actor === "narrator" || actor === "round-present" || /用户展示|开场白/.test(title); +} + +function isPlayFinalReply(msg, view) { + if (!isPlayView(view) || msg.role === "user") return false; + return shouldRenderPlayPresent(msg, view); +} + +function isPlayProcessMessage(msg, view) { + if (!isPlayView(view) || msg.role === "user") return false; + return !isPlayFinalReply(msg, view); } function formatPlayPresentHtml(body, view) { - const trimmed = (body || "").trim(); + const trimmed = stripPresentSourceFences((body || "").trim()); if (!trimmed) return `

(无正文)

`; const tweaks = view?.presentationTweaks; const fallbackShell = @@ -562,7 +635,7 @@ function wireRailChrome() { function hasStartedCreation(view) { if (!view?.id) return false; - if (view.lifecycleStage === "play") return true; + if (isPlayView(view)) return true; if (hasUserMessages(view)) return true; const reason = view.waitingReason?.kind; if (reason && reason !== "intake") return true; @@ -626,6 +699,7 @@ function shouldShowInFeed(msg, view) { if (HIDE_KINDS.has(kind)) return false; if (FEED_HIDDEN_KINDS.has(kind)) return false; if (msg.compressed) return false; + if (isPlayView(view) && isPlayHousekeepingMessage(msg)) return false; if (isSkillSelectionMessage(msg)) return false; if (isReviewSidecarQuestionStub(msg)) return false; if ( @@ -635,11 +709,11 @@ function shouldShowInFeed(msg, view) { ) { return false; } - if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact") { + if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact" && !isPlayView(view)) { return false; } - // 创作:编排器思考进「调度」,不占主区;游玩保持原样 - if (kind === "orchestrator_thinking" && view.lifecycleStage !== "play") { + // 创作:编排器思考进「调度」,不占主区;游玩保持原样(默认折叠) + if (kind === "orchestrator_thinking" && !isPlayView(view)) { return false; } if (kind === "orchestrator_prompt") return false; @@ -661,10 +735,10 @@ function maybeSyncRail(view) { } export function renderLifecycle(view) { - const toggle = document.getElementById("lifecycle-toggle"); - if (!toggle) return; const stage = view.lifecycleStage ?? "design"; document.body.dataset.lifecycle = stage; + const toggle = document.getElementById("lifecycle-toggle"); + if (!toggle) return; toggle.querySelectorAll("[data-stage]").forEach((btn) => { const s = btn.getAttribute("data-stage"); btn.classList.toggle("active", s === stage); @@ -1541,7 +1615,7 @@ function renderKnownArtifactHtml(doc, opts = {}) { if (schema === "context-fragment.v1" || isContextFragmentLike(doc)) { return renderContextFragmentHtml(doc, opts); } - if (schema === "context-order.v1" || Array.isArray(doc.slots)) { + if (schema === "context-order.v1" || Array.isArray(doc.slots) || Array.isArray(doc.agents)) { const orderHtml = renderContextOrderHtml(doc); if (orderHtml) return orderHtml; } @@ -1666,6 +1740,105 @@ function renderContextFragmentHtml(doc, opts = {}) { return `
${parts.join("")}
`; } +/** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */ +function collectOpeningCandidateTexts(body) { + const texts = []; + const push = (value) => { + const text = String(value ?? "").trim(); + if (text && !texts.includes(text)) texts.push(text); + }; + push(body.开场白全文); + if (Array.isArray(body.开场白候选)) { + for (const item of body.开场白候选) { + if (typeof item === "string") push(item); + else if (item && typeof item === "object") { + push(item.全文 || item.text || item.开场白 || item.内容); + } + } + } + return texts; +} + +function renderOpeningSetupBodyHtml(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return ""; + const candidates = collectOpeningCandidateTexts(body); + if (!candidates.length) return ""; + const total = candidates.length; + const slides = candidates + .map((text, i) => { + const hidden = i === 0 ? "" : " hidden"; + const selected = i === 0 ? " is-selected" : ""; + return `
${renderPresentShellHtml( + presentFromPlain(text, "prose"), + esc, + )}
`; + }) + .join(""); + const nav = + total > 1 + ? `
+ + 1 / ${total} + +
` + : ""; + const vars = Array.isArray(body.开场变量) ? body.开场变量 : []; + const varsHtml = vars.length + ? `

开场变量

    ${vars + .map((row) => { + const name = row?.名 || row?.name || row?.key || ""; + const val = row?.值 !== undefined ? row.值 : row?.value; + const note = row?.依据 || row?.note || ""; + const noteHtml = note + ? ` (${esc(String(note))})` + : ""; + return `
  • ${esc(String(name))}:${esc(String(val ?? ""))}${noteHtml}
  • `; + }) + .join("")}
` + : ""; + const space = body.用户可行动空间 + ? `

可行动空间

${renderProseHtml( + body.用户可行动空间, + )}
` + : ""; + return `
+
+ ${slides} + ${nav} +
+ ${varsHtml} + ${space} +
`; +} + +function wireOpeningPicker() { + document.querySelectorAll("[data-opening-picker]").forEach((root) => { + if (root.dataset.wired === "1") return; + root.dataset.wired = "1"; + const total = Math.max(1, Number(root.getAttribute("data-total")) || 1); + const show = (index) => { + const next = ((index % total) + total) % total; + root.setAttribute("data-selected", String(next)); + root.querySelectorAll("[data-opening-index]").forEach((el) => { + const i = Number(el.getAttribute("data-opening-index")); + const on = i === next; + el.toggleAttribute("hidden", !on); + el.classList.toggle("is-selected", on); + }); + const pos = root.querySelector("[data-opening-pos]"); + if (pos) pos.textContent = `${next + 1} / ${total}`; + }; + root.addEventListener("click", (e) => { + const btn = e.target.closest("[data-opening-delta]"); + if (!btn || !root.contains(btn)) return; + const delta = Number(btn.getAttribute("data-opening-delta")); + if (delta !== 1 && delta !== -1) return; + const cur = Number(root.getAttribute("data-selected") || 0); + show(cur + delta); + }); + }); +} + /** 按技能分流正文视图;无专用模板则返回 ""(调用方走通用结构化) */ function renderSpecialtyBodyHtml(body, skill) { if (!body || typeof body !== "object" || Array.isArray(body)) return ""; @@ -1703,6 +1876,22 @@ function renderSpecialtyBodyHtml(body, skill) { const html = renderNarrativeBodyHtml(body); if (html) return html; } + if ( + body.呈现壳 != null || + body.可见块 != null || + (body.示例 != null && (skillName.includes("正文组成") || skillName.includes("回复格式"))) + ) { + const html = renderReplyFormatBodyHtml(body); + if (html) return html; + } + if ( + body.开场白全文 != null || + body.开场白候选 != null || + skillName.includes("开场白") + ) { + const html = renderOpeningSetupBodyHtml(body); + if (html) return html; + } const looksAesthetics = body.设定逻辑 != null || @@ -1782,6 +1971,101 @@ function renderSpecialtyBodyHtml(body, skill) { `; } +/** 正文组成 · 壳适配 + 示例灌数(present 壳预览,验收美化) */ +function renderReplyFormatBodyHtml(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return ""; + const shellDoc = body.呈现壳 && typeof body.呈现壳 === "object" ? body.呈现壳 : {}; + const shellId = + typeof shellDoc.shell_id === "string" && PRESENT_SHELL_IDS.includes(shellDoc.shell_id) + ? shellDoc.shell_id + : typeof shellDoc.shell_id === "string" + ? shellDoc.shell_id + : ""; + const tweaks = + shellDoc.微调 && typeof shellDoc.微调 === "object" && !Array.isArray(shellDoc.微调) + ? shellDoc.微调 + : {}; + const example = body.示例 && typeof body.示例 === "object" ? body.示例 : null; + const packetRaw = example?.灌数; + const presentView = + packetRaw && typeof packetRaw === "object" + ? parsePresentDoc( + { + ...packetRaw, + shell: packetRaw.shell || packetRaw.shell_id || shellId || "prose", + }, + shellId || "prose", + ) + : null; + + const parts = []; + parts.push(`
+ 本步看壳与美化 + 示例非正史 · 文笔不重要 +
`); + + const metaBits = []; + if (shellId) { + metaBits.push(`壳 ${esc(shellId)}`); + } + if (shellDoc.为何选它) { + metaBits.push(`${esc(String(shellDoc.为何选它))}`); + } + if (body.版式隐喻) { + metaBits.push(`隐喻:${esc(String(body.版式隐喻))}`); + } + if (metaBits.length) { + parts.push(`
${metaBits.join("")}
`); + } + + if (presentView) { + const nature = example?.性质 ? esc(String(example.性质)) : "验收预览|非正史"; + const explain = example?.用来说明 ? `

${esc(String(example.用来说明))}

` : ""; + parts.push(`
+

版式预览 ${nature}

+ ${explain} + ${renderPresentShellHtml(presentView.packet, esc, { tweaks })} +
`); + } else { + parts.push( + `

(暂无示例灌数;定稿时应给 present.v1 预览)

`, + ); + } + + const contractKeys = [ + "依据的体验与呈现", + "呈现壳", + "版式隐喻", + "可见块", + "监控栏锚点", + "隐藏段", + "前端拆分与美化", + "拼装与终稿", + ]; + const used = new Set(["示例"]); + for (const key of contractKeys) { + if (body[key] == null || body[key] === "") continue; + used.add(key); + parts.push( + `
${esc(key)}${renderStructuredValueHtml( + body[key], + 0, + )}
`, + ); + } + for (const [k, v] of Object.entries(body)) { + if (used.has(k) || v == null || v === "") continue; + parts.push( + `
${esc(k)}${renderStructuredValueHtml( + v, + 0, + )}
`, + ); + } + + return `
${parts.join("")}
`; +} + /** 叙事指南 · 一份全文结构化卡(不强调双投影裁剪) */ function renderNarrativeBodyHtml(body) { const order = [ @@ -2528,7 +2812,10 @@ function renderSelfScoreHtml(自评) { function renderProbeHtml(追问) { if (!追问 || typeof 追问 !== "object") return ""; - const qs = Array.isArray(追问.题目) ? 追问.题目 : []; + const qs = Array.isArray(追问.题目) + ? 追问.题目.filter((q) => q && typeof q === "object" && (q.问 || q.prompt)) + : []; + if (!qs.length) return ""; const cards = []; if (追问.导语) { cards.push(`

${esc(String(追问.导语))}

`); @@ -2555,7 +2842,13 @@ function renderProbeHtml(追问) { } function renderContextOrderHtml(doc) { - if (!Array.isArray(doc.slots) || !doc.slots.length) return null; + const slots = + Array.isArray(doc.slots) && doc.slots.length + ? doc.slots + : Array.isArray(doc.agents) + ? doc.agents.filter((a) => a && a.enabled !== false) + : []; + if (!slots.length) return null; const parts = []; if (doc.brief) { parts.push( @@ -2564,7 +2857,7 @@ function renderContextOrderHtml(doc) { } for (const slot of doc.slots) { if (!slot || typeof slot !== "object") continue; - const title = slot.label || slot.ref || "槽"; + const title = slot.label || slot.ref || slot.id || "槽"; const inserts = Array.isArray(slot.inserts) ? slot.inserts : []; if (!inserts.length && Array.isArray(slot.order)) { // 扁平序:直接是条目列表 @@ -2919,7 +3212,7 @@ function renderCreationFlowView(flowView) { return ``; } if (!flowView.steps?.length) return ""; @@ -2943,7 +3236,9 @@ function renderCreationFlowView(flowView) { ? s.depends_on.map((d) => esc(d)).join("、") : "无"; const occ = - s.occurrence && s.occurrence > 1 + s.mode === "revise" + ? `回头修改` + : s.occurrence && s.occurrence > 1 ? `第 ${esc(String(s.occurrence))} 次` : s.repeatable ? `可反复` @@ -3068,12 +3363,16 @@ function renderReviewWorkspace(review, opts = {}) { asPresentFallback: review.workerId === "narrator" || review.workerId === "opening-generator" || + (Array.isArray(review.outputTags) && + review.outputTags.includes("设计.开场白与开场变量")) || /用户展示|开场白/.test(review.body || ""), }); const contextId = review.sourceMessageId || review.id || ""; const hasContext = review.contextTrace ? "1" : "0"; const label = displayWorkerLabel(review.workerId) || "产物"; - const copy = reviewComposerCopy(review.workerId); + const copy = reviewComposerCopy(review.workerId, { + outputTags: review.outputTags, + }); const sourceMsg = opts.sourceMessage || (review.sourceMessageId @@ -3211,25 +3510,65 @@ export function renderAgentPanel(view, loading) { timelineEl.scrollTop = timelineEl.scrollHeight; } +function coordOriginalText(msg) { + return String(msg?.body ?? msg?.text ?? "").trim(); +} + +/** 一轮 = 一次用户输入 + 直到下一次输入前的全部输出。 */ +function groupCoordTurnPacks(messages) { + const packs = []; + for (const msg of messages) { + const isUser = msg.role === "user"; + const last = packs[packs.length - 1]; + if (isUser || !last) { + packs.push({ user: isUser ? msg : null, outputs: isUser ? [] : [msg] }); + } else { + last.outputs.push(msg); + } + } + return packs; +} + +function packAnchorMessage(pack) { + return pack.user || pack.outputs[0] || null; +} + +function packLastMessage(pack) { + return pack.outputs[pack.outputs.length - 1] || pack.user || null; +} + function renderCoordLine(msg, view, opts = {}) { const isUser = msg.role === "user"; const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); const who = isUser ? "你" : msgLabel(msg); + const full = coordOriginalText(msg) || msgBody(msg, view); const raw = msgBody(msg, view).replace(/\s+/g, " ").trim(); const text = raw.length > 320 ? `${raw.slice(0, 320)}…` : raw; const nav = renderVariantNavHtml(msg); + const inPack = opts.inPack === true; const allMessages = view.messages ?? []; - const messageIndex = allMessages.findIndex((item) => item.id === msg.id); - const rollbackMessageId = messageIndex >= 0 ? allMessages[messageIndex + 1]?.id : null; + const rollbackMessageId = inPack ? null : getRollbackMessageId(allMessages, msg.id); const rollbackAttr = rollbackMessageId ? ` data-rollback-message-id="${esc(rollbackMessageId)}"` : ""; const contextAttr = msg.contextTrace ? ` data-has-context="1"` : ""; const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : ""; + const originalAttr = full ? ` data-original-text="${escAttr(full)}"` : ""; + const menu = inPack + ? "" + : ``; const trace = msg.contextTrace; + const variantTotal = Math.max(1, Number(msg.branchTotal) || 1); + const variantIndex = Math.min(variantTotal, Math.max(1, (msg.branchIndex ?? 0) + 1)); + const requestBits = [ + opts.requestIndex ? `请求 ${opts.requestIndex}` : "", + variantTotal > 1 ? `${variantIndex}/${variantTotal} 版` : "", + ].filter(Boolean); + const requestHint = requestBits.length ? requestBits.join(" · ") : "本次请求"; const traceMeta = trace - ? ` @@ -3240,14 +3579,73 @@ function renderCoordLine(msg, view, opts = {}) { ` : ""; const traceClass = trace ? " coord-line--trace" : ""; - return `
- + return `
+ ${menu}
${esc(who)}${esc(kind)}${fmtTime(msg.createdAt)}${nav}
${esc(text || "(空)")}
${traceMeta}
`; } +function renderCoordPack(pack, view, opts = {}) { + const anchor = packAnchorMessage(pack); + if (!anchor) return ""; + const last = packLastMessage(pack); + const pending = Boolean(pack.user) && pack.outputs.length === 0; + const userText = pack.user ? coordOriginalText(pack.user) : ""; + const allMessages = view.messages ?? []; + let rollbackMode = ""; + let rollbackId = ""; + if (pack.user) { + rollbackMode = "pending-input"; + rollbackId = pack.user.id; + } else if (last) { + const nextId = getRollbackMessageId(allMessages, last.id); + if (nextId) { + rollbackMode = "keep"; + rollbackId = nextId; + } + } + const inCount = pack.user ? 1 : 0; + const outCount = pack.outputs.length; + const meta = pending ? "待输入" : `${inCount} 入 · ${outCount} 出`; + const rollbackAttr = rollbackId + ? ` data-rollback-message-id="${esc(rollbackId)}" data-rollback-mode="${esc(rollbackMode)}"` + : ""; + const restoreAttr = + rollbackMode === "pending-input" && userText + ? ` data-restore-draft="${escAttr(userText)}"` + : ""; + const originalAttr = userText ? ` data-original-text="${escAttr(userText)}"` : ""; + const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : ""; + const tracedOutputCount = pack.outputs.filter((msg) => msg.contextTrace).length; + let requestIndex = 0; + const lines = [ + pack.user + ? renderCoordLine(pack.user, view, { + inPack: true, + traceOnly: opts.traceOnly || opts.hiddenIds?.has(pack.user.id), + }) + : "", + ...pack.outputs.map((msg) => { + const thisRequest = + tracedOutputCount > 1 && msg.contextTrace ? ++requestIndex : 0; + return renderCoordLine(msg, view, { + inPack: true, + traceOnly: opts.traceOnly || opts.hiddenIds?.has(msg.id), + requestIndex: thisRequest || undefined, + }); + }), + ].join(""); + return `
+ +
+ ${esc(meta)} +
+ ${lines} +
`; +} + function getRollbackMessageId(messages, messageId) { const messageIndex = messages.findIndex((item) => item.id === messageId); return messageIndex >= 0 ? messages[messageIndex + 1]?.id ?? null : null; @@ -3255,7 +3653,7 @@ function getRollbackMessageId(messages, messageId) { /** @returns {"speak"|"answer"|"review"|"busy"|null} */ function resolveDesignSurface(view, loading) { - if (view.lifecycleStage === "play") return null; + if (isPlayView(view)) return null; if (view.phase === "done") return null; const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason)); if (busy) return "busy"; @@ -3300,6 +3698,12 @@ function proposedOutputCopy(proposed) { typeof target === "string" || typeof target === "number" ? String(target).trim() : ""; + if (proposed?.mode === "revise") { + if (targetText) { + return `将在既有「${targetText}」的${proposed.name}上继续改,不是从零再生成。`; + } + return `将在既有「${proposed?.name || "该步"}」上继续改,不是从零再生成。`; + } if (targetText) { return `将生成「${targetText}」的${proposed.name}。`; } @@ -3347,17 +3751,24 @@ function renderSpeakWorkspace(view) { hint = "写清楚要改哪里;发送后在现有产物上修改。"; } else if (wr?.kind === "next_intent") { title = "下一步想写什么"; - hint = "说说接下来想做什么;可留空,发送后会展示下一节点供确认。"; + hint = "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点供确认。"; } else if (wr?.kind === "approve_step") { title = view.proposedNextStep?.name - ? `接下来生成 · ${view.proposedNextStep.name}` + ? view.proposedNextStep.mode === "revise" + ? `回头修改 · ${view.proposedNextStep.name}` + : `接下来生成 · ${view.proposedNextStep.name}` : "确认下一步"; hint = view.proposedNextStep ? proposedOutputCopy(view.proposedNextStep) : view.focus?.detail || "确认执行,或在底栏说明意见。"; } else if (wr?.kind === "input") { - title = "继续说"; - hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。"; + if (wr.message && /创作已收口/.test(wr.message)) { + title = "创作已收口"; + hint = wr.message; + } else { + title = "继续说"; + hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。"; + } } else if (view.uiPrompt && !hasUserMessages(view)) { title = "描述你想创作什么"; hint = String(view.uiPrompt).trim().slice(0, 280); @@ -3400,19 +3811,25 @@ function fillCoordRail(visible, view, handlers) { return visibleIds.has(message.id) || Boolean(message.contextTrace); }); const traceCount = history.filter((message) => message.contextTrace).length; + const packs = groupCoordTurnPacks(history); + const hiddenIds = new Set( + history.filter((message) => !visibleIds.has(message.id)).map((message) => message.id), + ); coordRail.hidden = false; if (drawerBody) { - drawerBody.innerHTML = history.length - ? history - .map((m) => renderCoordLine(m, view, { traceOnly: !visibleIds.has(m.id) })) - .join("") + drawerBody.innerHTML = packs.length + ? packs.map((pack) => renderCoordPack(pack, view, { hiddenIds })).join("") : `

暂无对话摘要

`; wireMessageFeedActions(drawerBody, handlers); wireMessageContextMenu(drawerBody, handlers); } if (drawerCount) { - drawerCount.textContent = history.length ? String(history.length) : ""; - drawerCount.title = traceCount ? `${traceCount} 条保留了完整请求上下文` : ""; + drawerCount.textContent = packs.length ? String(packs.length) : ""; + drawerCount.title = traceCount + ? `${packs.length} 轮,其中 ${traceCount} 条保留了完整请求上下文` + : packs.length + ? `${packs.length} 轮对话` + : ""; } syncCoordRailChrome(); } @@ -3444,6 +3861,7 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) { }) + askSlot; wireMessageFeedActions(stage, handlers); wireMessageContextMenu(stage, handlers); + wireOpeningPicker(); if (opts.mountAskCard) { const askHost = document.getElementById("workspace-review-ask"); if (askHost) ensureQuestionsHostIn(askHost); @@ -3466,13 +3884,16 @@ export function renderMessageFeed(view, loading, handlers = {}) { const surface = resolveDesignSurface(view, loading); const designWorkspace = surface != null; const reviewingNow = - view.waitingReason?.kind === "review_artifact" && Boolean(view.reviewArtifact); + !isPlayView(view) && + view.waitingReason?.kind === "review_artifact" && + Boolean(view.reviewArtifact); document.body.classList.toggle("is-reviewing", reviewingNow); document.body.classList.toggle("design-workspace", designWorkspace); if (reviewingNow) { document.body.dataset.reviewKind = reviewComposerCopy( view.reviewArtifact?.workerId, + { outputTags: view.reviewArtifact?.outputTags }, ).kind; } else { delete document.body.dataset.reviewKind; @@ -3529,9 +3950,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
${esc(label)} 流式输出中 + ${renderRetryRunButton()}
${renderLiveStreamBody(live, view)}
`; + wireRetryRun(stage, handlers); return; } @@ -3565,8 +3988,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (!visible.length && !loading) { const p = document.createElement("p"); p.className = "empty"; - if (view.lifecycleStage === "play") { - p.textContent = "游玩模式:Agent 将按 Worker 集调度,推进世界与叙事。"; + if (isPlayView(view)) { + p.textContent = "开场已就绪。在下方说你要做什么。"; } else if (view.uiPrompt) { const recipeLine = view.selectedRecipe?.name ? `\n\n已选配方:${view.selectedRecipe.name}` @@ -3621,31 +4044,48 @@ export function renderMessageFeed(view, loading, handlers = {}) { const questionsActive = kind === "worker_questions" && Boolean(activeQuestions); const showThinking = - !isUser && msg.thinking && view.lifecycleStage === "play"; - const bodyHtml = questionsActive + !isUser && msg.thinking && isPlayView(view); + const innerBody = questionsActive ? `

${esc(body)}

` : kind === "worker_questions" ? formatQuestionsHtml(body) - : kind === "worker_output" - ? formatArtifactBodyHtml(body) - : shouldRenderPlayPresent(msg, view) - ? formatPlayPresentHtml(body, view) + : shouldRenderPlayPresent(msg, view) + ? formatPlayPresentHtml(body, view) + : kind === "worker_output" + ? formatArtifactBodyHtml(body) : esc(body); - card.innerHTML = ` -
- ${isUser ? "" : `
${headInner}${menuTrigger}
`} - ${showThinking ? renderThinkingBlock(msg.thinking) : ""} -
${bodyHtml}
- ${isUser ? `
${headInner}${menuTrigger}
` : ""} -
`; + const process = !isUser && isPlayProcessMessage(msg, view); + const playFinal = !isUser && isPlayFinalReply(msg, view); + if (process) { + card.classList.add("msg-play-process-row"); + card.innerHTML = ` +
+ + ${esc(label)} + ${showSubtitle ? `${esc(subtitle)}` : ""} + ${fmtTime(msg.createdAt)} + + ${showThinking ? renderThinkingBlock(msg.thinking) : ""} +
${innerBody}
+
`; + } else { + if (playFinal) card.classList.add("msg-play-final"); + card.innerHTML = ` +
+ ${isUser ? "" : `
${headInner}${menuTrigger}
`} + ${showThinking ? renderThinkingBlock(msg.thinking) : ""} +
${innerBody}
+ ${isUser ? `
${headInner}${menuTrigger}
` : ""} +
`; + } feed.appendChild(card); } wireMessageFeedActions(feed, handlers); wireMessageContextMenu(feed, handlers); - if (reviewingNow && view.reviewArtifact && !loading && stage) { + if (reviewingNow && view.reviewArtifact && !loading && stage && !isPlayView(view)) { const hasHungAsk = Boolean(activeQuestions?.questions?.length); mountReviewWorkspace(stage, view, handlers, { hideAskSidecar: hasHungAsk, @@ -3661,10 +4101,11 @@ export function renderMessageFeed(view, loading, handlers = {}) { const label = live?.label ?? view.focus?.action ?? "处理中"; pending.innerHTML = `
-
${esc(label)}流式输出中
+
${esc(label)}流式输出中${renderRetryRunButton()}
${renderLiveStreamBody(live, view)}
`; feed.appendChild(pending); + wireRetryRun(pending, handlers); } feed.scrollTop = feed.scrollHeight; @@ -3682,7 +4123,7 @@ export function updateLiveStreamPanel(view) { if (labelEl && live?.label) labelEl.textContent = live.label; const next = renderLiveStreamBody(live, view); const hadThinking = Boolean(body.querySelector(".msg-live-pre")); - if (next === "…" && hadThinking) return; + if (next.includes("msg-live-empty") && hadThinking) return; body.innerHTML = next; const pre = body.querySelector(".msg-live-pre:last-of-type"); if (pre) pre.scrollTop = pre.scrollHeight; diff --git a/web/app.js b/web/app.js index bb4dc2a..86162ff 100644 --- a/web/app.js +++ b/web/app.js @@ -15,13 +15,17 @@ let activeBookId = null; let lastView = null; let books = []; let composerForceInput = false; -let sidebarNav = { level: "root" }; +let pendingComposerDraft = ""; +let pendingComposerDraftSeq = 0; let bookSelectMode = false; let selectedBookIds = new Set(); +const expandedBookIds = new Set(); +let activePlaySaveId = null; const playSavesByBook = new Map(); const playSavesLoading = new Set(); let bookMenuBookId = null; let livePollTimer = null; +let inflightSeq = 0; const LIVE_POLL_MS = 280; @@ -39,9 +43,14 @@ const USER_TASK = { function reviewCopyFromView(view) { return reviewComposerCopy(view?.reviewArtifact?.workerId, { hasQuestions: Boolean(getActiveQuestions(view)?.questions?.length), + outputTags: view?.reviewArtifact?.outputTags, }); } +function isPlayView(view) { + return Boolean(view?.playLayerActive || view?.lifecycleStage === "play"); +} + /** * 把 waitingReason 收成用户三态:说话 / 答题+自由发挥 / 批阅。 * @returns {{ id: 'speak'|'answer'|'review'|'busy'|'idle'|'error', label: string, title: string, hint: string|null }} @@ -79,6 +88,15 @@ function resolveUserTask(view, loading) { const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length); const moduleOpening = isModuleOpeningWaiting(view); + if (isPlayView(view) && wr?.kind !== "worker_questions") { + return { + id: "speak", + label: USER_TASK.speak.label, + title: "继续说", + hint: "你说一句,世界推进一轮。", + }; + } + if (wr?.kind === "review_artifact") { const copy = reviewCopyFromView(view); return { @@ -114,7 +132,11 @@ function resolveUserTask(view, loading) { return { id: "speak", label: "确认", - title: proposed?.name ? `接下来生成 · ${proposed.name}` : "确认下一步", + title: proposed?.name + ? proposed.mode === "revise" + ? `回头修改 · ${proposed.name}` + : `接下来生成 · ${proposed.name}` + : "确认下一步", hint: proposed ? proposedOutputCopy(proposed) : view.focus?.detail ?? "确认执行,或说明意见。", @@ -126,7 +148,7 @@ function resolveUserTask(view, loading) { id: "speak", label: USER_TASK.speak.label, title: "下一步想写什么", - hint: "说说接下来想做什么;可留空,发送后会展示下一节点。", + hint: "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点。", }; } @@ -208,6 +230,12 @@ function proposedOutputCopy(proposed) { typeof target === "string" || typeof target === "number" ? String(target).trim() : ""; + if (proposed?.mode === "revise") { + if (targetText) { + return `将在既有「${targetText}」的${proposed.name}上继续改,不是从零再生成。`; + } + return `将在既有「${proposed?.name || "该步"}」上继续改,不是从零再生成。`; + } if (targetText) { return `将生成「${targetText}」的${proposed.name}。`; } @@ -216,6 +244,7 @@ function proposedOutputCopy(proposed) { /** 确认下一步:给人看「生成什么」;有 target 时顺带露出后台 id,便于改对象时同步。 */ function proposeVisibleFields(proposed) { + if (proposed?.kind === "prior-artifact") return []; const missing = new Set(proposed?.paramsMissing || []); const specs = Array.isArray(proposed?.paramSpecs) ? proposed.paramSpecs : []; const byKey = new Map(specs.map((s) => [s.key, s])); @@ -288,6 +317,19 @@ function resolveComposer(view, loading) { }; } + if (isPlayView(view)) { + const send = view.actions?.find((a) => a.type === "send_message"); + return { + mode: "input", + task, + taskTitle: task.title || "继续说", + taskHint: task.hint, + placeholder: send?.placeholder ?? "说你要做什么…", + hint: null, + submitLabel: "发送", + }; + } + const confirmIntake = view.actions?.find((a) => a.type === "confirm_intake"); if (view.waitingReason?.kind === "intake" && view.intake) { const hasUser = (view.messages ?? []).some((m) => m.role === "user"); @@ -466,16 +508,22 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) { } function renderComposer(view, loading) { - const root = $("composer"); - if (!root) return; - if (root._enterHandler) { - document.removeEventListener("keydown", root._enterHandler); - root._enterHandler = null; + if (pendingComposerDraft) composerForceInput = true; + const host = $("composer"); + const root = $("composer-main") || host; + if (!root || !host) return; + if (host._enterHandler) { + document.removeEventListener("keydown", host._enterHandler); + host._enterHandler = null; } const spec = resolveComposer(view, loading); if (spec.mode === "waiting") { - root.innerHTML = `
${esc(spec.text)}
`; + root.innerHTML = `
+ ${esc(spec.text)} + +
`; + root.querySelector("[data-act=retry_run]")?.addEventListener("click", () => retryRun()); return; } if (spec.mode === "idle") { @@ -502,6 +550,7 @@ function renderComposer(view, loading) {
${shell}
`; wireComposerForm(); root.querySelector("[data-act=confirm_intake]")?.addEventListener("click", () => runAction("confirm_intake")); + applyPendingComposerDraft(); return; } @@ -554,13 +603,22 @@ function renderComposer(view, loading) { visible.some((f) => f.key === "target") && visible.some((f) => f.key === "rule_id") ? `

改生成对象时,后台 id 也要改成对应英文(如丧尸怪物 → zombies)。

` : ""; + const planHint = + p.kind === "prior-artifact" + ? `

${ + originalTarget + ? "编排器已规划对象,步骤内可修订。" + : "具体写什么在步骤内确定。" + }

` + : ""; root.innerHTML = `
- 下一步 + ${p.mode === "revise" ? "回头修改" : "下一步"} ${esc(p.name)}

${esc(proposedOutputCopy(p))}

+ ${planHint} ${fields ? `
${fields}
` : ""} ${syncHint}
@@ -658,6 +716,7 @@ function renderComposer(view, loading) { root.innerHTML = `
${shell}
`; wireComposerForm({ acceptOnEmpty }); root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); + applyPendingComposerDraft(); } function composerTextareaMaxPx(el) { @@ -680,6 +739,23 @@ function autosizeComposerInput(el) { el.style.overflowY = el.scrollHeight > max + 1 ? "auto" : "hidden"; } +function applyPendingComposerDraft() { + if (!pendingComposerDraft) return; + if (pendingComposerDraftSeq && pendingComposerDraftSeq !== inflightSeq) { + pendingComposerDraft = ""; + pendingComposerDraftSeq = 0; + return; + } + const input = $("composer-input"); + if (!input) return; + input.value = pendingComposerDraft; + pendingComposerDraft = ""; + pendingComposerDraftSeq = 0; + autosizeComposerInput(input); + input.focus(); + refreshReviewComposerChrome(); +} + /** 无输入框的确认态:Enter = 主按钮 */ function wireComposerActionEnter(action, opts = {}) { const root = $("composer"); @@ -777,14 +853,41 @@ function wireComposerForm(opts = {}) { input?.focus(); } +function isPlayNow() { + return isPlayView(lastView); +} + +function playRunsForBook(bookId) { + return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "run"); +} + function bookPlayReady(bookId) { - return lastView?.bookId === bookId && Boolean(lastView?.playReady); + if (lastView?.bookId === bookId && Boolean(lastView?.playReady)) return true; + const saves = playSavesByBook.get(bookId); + if (!Array.isArray(saves)) return false; + return saves.some((s) => s.kind === "instance" || s.kind === "run"); +} + +function bookHasPlayBranch(bookId) { + if (bookPlayReady(bookId)) return true; + if (playSavesLoading.has(bookId) && !playSavesByBook.has(bookId)) return true; + return playRunsForBook(bookId).length > 0; +} + +function formatSaveWhen(createdAt) { + const when = new Date(createdAt); + if (Number.isNaN(when.getTime())) return ""; + return when.toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); } async function fetchPlaySavesForBook(bookId, force = false) { if (!force && (playSavesByBook.has(bookId) || playSavesLoading.has(bookId))) return; playSavesLoading.add(bookId); - renderBookList(); try { const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`); playSavesByBook.set(bookId, data.saves ?? []); @@ -819,8 +922,21 @@ function hideBookMenu() { function refreshBookMenuLabels() { const n = selectedBookIds.size; const multi = bookSelectMode && n > 0; + const bookId = bookMenuBookId; + const ready = bookId ? bookPlayReady(bookId) : false; + const thisBook = bookId && bookId === activeBookId; $("book-menu-open")?.toggleAttribute("hidden", multi); - $("book-menu-rename")?.toggleAttribute("hidden", n !== 1); + $("book-menu-play")?.toggleAttribute("hidden", multi || !ready); + $("book-menu-save-instance")?.toggleAttribute("hidden", multi || !thisBook || !ready); + $("book-menu-save-play")?.toggleAttribute( + "hidden", + multi || !thisBook || !ready || !isPlayNow(), + ); + $("book-menu-export")?.toggleAttribute( + "hidden", + multi || !thisBook || !(lastView?.messages?.length), + ); + $("book-menu-rename")?.toggleAttribute("hidden", n > 1); const dup = $("book-menu-duplicate"); if (dup) dup.textContent = n > 1 ? `复制 ${n} 项备份` : "复制备份"; const del = $("book-menu-delete"); @@ -846,22 +962,166 @@ function showBookMenu(bookId, x, y) { } } -function getNavBook() { - if (sidebarNav.level !== "book") return null; - return books.find((b) => b.id === sidebarNav.bookId) ?? null; +function expandBook(bookId) { + if (bookId) expandedBookIds.add(bookId); } -function navigateToRoot() { - sidebarNav = { level: "root" }; - renderPathBar(); - renderBookList(); +function renderExplorerRow({ + name, + meta, + active, + selected, + bookId, + actionClass, + onClick, + onDelete, + child, + twistie, + expanded, + onTwistie, +}) { + const row = document.createElement("button"); + row.type = "button"; + row.className = [ + "explorer-row", + actionClass, + active ? "active" : "", + selected ? "selected" : "", + child ? "child" : "", + twistie ? "has-twist" : "", + ] + .filter(Boolean) + .join(" "); + if (bookId) row.dataset.bookId = bookId; + row.innerHTML = ` + ${twistie ? `${expanded ? "▾" : "▸"}` : ""} + + ${esc(name)} + ${meta ? `${esc(meta)}` : ""} + + ${onDelete ? `×` : ""}`; + row.addEventListener("click", (e) => { + if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return; + onClick?.(e); + }); + row.querySelector("[data-twist]")?.addEventListener("click", (e) => { + e.stopPropagation(); + onTwistie?.(); + }); + row.querySelector(".explorer-del")?.addEventListener("click", (e) => { + e.stopPropagation(); + onDelete?.(); + }); + if (bookId && !child) { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + if (bookSelectMode && !selectedBookIds.has(bookId)) { + selectedBookIds.add(bookId); + renderBookList(); + } + showBookMenu(bookId, e.clientX, e.clientY); + }); + } + return row; } -function navigateToBook(bookId) { - sidebarNav = { level: "book", bookId }; - renderPathBar(); - renderBookList(); - void fetchPlaySavesForBook(bookId); +function renderBookBranch(book, list) { + const loading = playSavesLoading.has(book.id); + const saves = playSavesByBook.get(book.id); + const ready = bookPlayReady(book.id); + const playHere = book.id === activeBookId && isPlayNow(); + + if (loading && saves === undefined) { + const hint = document.createElement("p"); + hint.className = "explorer-hint"; + hint.textContent = "加载存档…"; + list.appendChild(hint); + return; + } + + if (ready) { + list.appendChild( + renderExplorerRow({ + name: "当前", + meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", + active: playHere && !activePlaySaveId, + child: true, + onClick: () => void openCurrentPlay(book.id), + }), + ); + } + + for (const s of playRunsForBook(book.id)) { + const when = formatSaveWhen(s.createdAt); + list.appendChild( + renderExplorerRow({ + name: s.label, + meta: when ? `游玩 · ${when}` : "游玩进度", + active: playHere && activePlaySaveId === s.id, + child: true, + actionClass: "explorer-file", + onClick: () => void loadPlaySave(book.id, s.id), + onDelete: () => void deletePlaySave(book.id, s.id, s.label), + }), + ); + } + + if (ready) { + list.appendChild( + renderExplorerRow({ + name: "+ 新开一局", + actionClass: "explorer-action", + child: true, + onClick: () => void startNewPlayForBook(book.id), + }), + ); + } +} + +function renderBookList() { + const list = $("book-list"); + if (!list) return; + if (!books.length) { + list.innerHTML = ``; + $("btn-new-inline")?.addEventListener("click", openNewBookDialog); + return; + } + list.innerHTML = ""; + + for (const book of books) { + const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计"; + const selected = selectedBookIds.has(book.id); + const expanded = expandedBookIds.has(book.id); + const hasBranch = bookHasPlayBranch(book.id); + const bookActive = book.id === activeBookId && !isPlayNow() && !selected; + list.appendChild( + renderExplorerRow({ + name: book.title, + meta: skill, + active: bookActive, + selected, + bookId: book.id, + actionClass: "explorer-folder", + twistie: hasBranch, + expanded, + onTwistie: () => { + if (expandedBookIds.has(book.id)) expandedBookIds.delete(book.id); + else expandedBookIds.add(book.id); + renderBookList(); + if (!expandedBookIds.has(book.id)) return; + void fetchPlaySavesForBook(book.id); + }, + onClick: () => { + if (bookSelectMode) { + toggleBookSelection(book.id); + return; + } + void openBookDesign(book.id); + }, + }), + ); + if (expanded && hasBranch) renderBookBranch(book, list); + } } async function renameBookById(bookId) { @@ -969,8 +1229,9 @@ function setupBookBoxSelect() { }; list.addEventListener("mousedown", (e) => { - if (sidebarNav.level !== "root" || e.button !== 0) return; + if (e.button !== 0) return; if (e.target.closest("#book-action-menu")) return; + if (e.target.closest(".explorer-row.child")) return; dragging = true; start = { x: e.clientX, y: e.clientY }; overlay.hidden = true; @@ -1019,225 +1280,15 @@ function setupBookBoxSelect() { }); } -function pathHomeIcon() { - return ``; -} - -function renderPathBar() { - const bar = $("book-path-bar"); - if (!bar) return; - bar.innerHTML = ""; - - // 根级不占行;钻入作品后才显示面包屑 - const inBook = sidebarNav.level === "book"; - const book = inBook ? getNavBook() : null; - bar.hidden = !inBook || !book; - if (bar.hidden) return; - - const list = document.createElement("ol"); - list.className = "path-bar-list"; - - const rootLi = document.createElement("li"); - rootLi.className = "path-bar-item"; - const rootBtn = document.createElement("button"); - rootBtn.type = "button"; - rootBtn.className = "path-seg path-seg-root"; - rootBtn.dataset.nav = "root"; - rootBtn.title = "返回作品列表"; - rootBtn.innerHTML = `${pathHomeIcon()}作品`; - rootBtn.addEventListener("click", () => navigateToRoot()); - rootLi.appendChild(rootBtn); - - const sepLi = document.createElement("li"); - sepLi.className = "path-bar-item"; - sepLi.setAttribute("aria-hidden", "true"); - const sep = document.createElement("span"); - sep.className = "path-sep"; - sep.textContent = "›"; - sepLi.appendChild(sep); - - const bookLi = document.createElement("li"); - bookLi.className = "path-bar-item path-bar-item-current"; - const bookSeg = document.createElement("span"); - bookSeg.className = "path-seg current"; - bookSeg.setAttribute("aria-current", "page"); - bookSeg.textContent = book.title; - bookSeg.title = book.title; - bookLi.appendChild(bookSeg); - - list.append(rootLi, sepLi, bookLi); - bar.appendChild(list); -} - -function renderExplorerRow({ name, meta, active, selected, bookId, actionClass, onClick, onDelete }) { - const row = document.createElement("button"); - row.type = "button"; - row.className = ["explorer-row", actionClass, active ? "active" : "", selected ? "selected" : ""] - .filter(Boolean) - .join(" "); - if (bookId) row.dataset.bookId = bookId; - row.innerHTML = ` - - ${esc(name)} - ${meta ? `${esc(meta)}` : ""} - - ${onDelete ? `×` : ""}`; - row.addEventListener("click", (e) => { - if (e.target.closest(".explorer-del")) return; - onClick?.(e); - }); - row.querySelector(".explorer-del")?.addEventListener("click", (e) => { - e.stopPropagation(); - onDelete?.(); - }); - if (bookId) { - row.addEventListener("contextmenu", (e) => { - e.preventDefault(); - if (bookSelectMode && !selectedBookIds.has(bookId)) { - selectedBookIds.add(bookId); - renderBookList(); - } - showBookMenu(bookId, e.clientX, e.clientY); - }); - } - return row; -} - -function renderBookContents(book, list) { - const loading = playSavesLoading.has(book.id); - const saves = playSavesByBook.get(book.id); - const ready = bookPlayReady(book.id); - - list.appendChild( - renderExplorerRow({ - name: "继续创作", - meta: "自动保存", - active: activeBookId === book.id && lastView?.lifecycleStage !== "play", - onClick: () => { - void openBook(book.id).then(() => navigateToRoot()); - }, - }), - ); - - if (loading && saves === undefined) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; - list.appendChild(hint); - return; - } - - const saveList = saves ?? []; - if (!saveList.length && !ready) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "验收 Worker 集后可保存定稿 / 游玩存档"; - list.appendChild(hint); - return; - } - - if (saveList.length) { - const label = document.createElement("div"); - label.className = "explorer-section-label"; - label.textContent = "存档"; - list.appendChild(label); - } - - for (const s of saveList) { - const kindLabel = - s.kindLabel || - (s.kind === "instance" ? "创作定稿" : s.kind === "opening" ? "开局" : "游玩进度"); - const when = new Date(s.createdAt); - const shortWhen = Number.isNaN(when.getTime()) - ? "" - : when.toLocaleString("zh-CN", { - month: "numeric", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - list.appendChild( - renderExplorerRow({ - name: s.label, - meta: shortWhen ? `${kindLabel} · ${shortWhen}` : kindLabel, - onClick: () => void loadPlaySave(book.id, s.id), - onDelete: () => void deletePlaySave(book.id, s.id, s.label), - actionClass: "explorer-file", - }), - ); - } - - if (ready) { - list.appendChild( - renderExplorerRow({ - name: "+ 新建游玩", - actionClass: "explorer-action", - onClick: () => void startNewPlayForBook(book.id), - }), - ); - } else if (!saveList.length) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "暂无游玩存档"; - list.appendChild(hint); - } -} - -function renderBookList() { - const list = $("book-list"); - if (!list) return; - renderPathBar(); - if (!books.length) { - list.innerHTML = ``; - $("btn-new-inline")?.addEventListener("click", openNewBookDialog); - return; - } - list.innerHTML = ""; - - if (sidebarNav.level === "book") { - const book = getNavBook(); - if (!book) { - navigateToRoot(); - return; - } - renderBookContents(book, list); - return; - } - - for (const book of books) { - const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计"; - const selected = selectedBookIds.has(book.id); - list.appendChild( - renderExplorerRow({ - name: book.title, - meta: skill, - active: book.id === activeBookId && !selected, - selected, - bookId: book.id, - actionClass: "explorer-folder", - onClick: () => { - if (bookSelectMode) { - toggleBookSelection(book.id); - return; - } - navigateToBook(book.id); - void openBook(book.id); - }, - }), - ); - } -} - async function loadPlaySave(bookId, saveId) { try { + expandBook(bookId); if (lastView) renderSession(lastView, true); const data = await api( `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`, { method: "POST" }, ); - sidebarNav = { level: "root" }; + activePlaySaveId = saveId === "play-working" ? null : saveId; renderSession(data.session, false); } catch (err) { if (lastView) renderSession(lastView, false); @@ -1252,6 +1303,7 @@ async function deletePlaySave(bookId, saveId, label) { `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, { method: "DELETE" }, ); + if (activePlaySaveId === saveId) activePlaySaveId = null; await fetchPlaySavesForBook(bookId, true); } catch (err) { alert(err.message); @@ -1264,9 +1316,10 @@ async function startNewPlayForBook(bookId) { return; } try { + expandBook(bookId); if (lastView) renderSession(lastView, true); const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" }); - sidebarNav = { level: "root" }; + activePlaySaveId = null; renderSession(data.session, false); } catch (err) { if (lastView) renderSession(lastView, false); @@ -1274,39 +1327,85 @@ async function startNewPlayForBook(bookId) { } } +async function openBookDesign(bookId) { + expandBook(bookId); + activePlaySaveId = null; + if (activeBookId !== bookId || !sessionId) { + await openBook(bookId); + } + if (isPlayNow()) { + await setLifecycle("design"); + return; + } + renderBookList(); +} + +async function openCurrentPlay(bookId) { + expandBook(bookId); + const alreadyHere = + activeBookId === bookId && + isPlayNow() && + !activePlaySaveId && + sessionId && + lastView?.waitingReason?.kind !== "review_artifact"; + if (alreadyHere) { + renderBookList(); + return; + } + if (activeBookId !== bookId || !sessionId) { + await openBook(bookId); + } + if (activePlaySaveId && lastView?.playLayerActive) { + try { + const data = await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent("play-working")}/load`, + { method: "POST" }, + ); + activePlaySaveId = null; + renderSession(data.session, false); + return; + } catch { + /* 尚无工作副本则走生命周期入口 */ + } + } + activePlaySaveId = null; + await setLifecycle("play"); +} + function renderHeader(view, loading) { - const task = resolveUserTask(view, loading); const st = statusFor(view, loading); - $("work-title").textContent = view.bookTitle ?? "未命名作品"; - const skill = displaySkillPackLabel(view.activeSkill) || - view.selectedRecipe?.name || - "配方"; - $("work-meta").textContent = `${skill} · ${task.label}`; + const title = view.bookTitle ?? "未命名作品"; + document.title = `${title} · Writing Agent`; $("status-dot").className = `status-dot ${st.cls}`; $("status-text").textContent = st.text; - const showSavePlay = Boolean(view.bookId && view.playReady && view.lifecycleStage === "play"); + const showSavePlay = Boolean( + view.bookId && view.playReady && view.lifecycleStage === "play" && view.playLayerActive, + ); const showSaveInstance = Boolean(view.bookId && view.playReady); - $("btn-save-play").hidden = !showSavePlay; + const btnPlay = $("btn-save-play"); + if (btnPlay) btnPlay.hidden = !showSavePlay; const btnInst = $("btn-save-instance"); if (btnInst) btnInst.hidden = !showSaveInstance; - $("btn-export").disabled = !(view.messages?.length); + const btnExport = $("btn-export"); + if (btnExport) btnExport.disabled = !(view.messages?.length); } function renderEmpty() { sessionId = null; lastView = null; activeBookId = null; - sidebarNav = { level: "root" }; + activePlaySaveId = null; setBookSelectMode(false); composerForceInput = false; - $("work-title").textContent = "未打开作品"; - $("work-meta").textContent = ""; + document.title = "Writing Agent"; $("status-dot").className = "status-dot"; $("status-text").textContent = "—"; - $("btn-save-play").hidden = true; + const btnPlay = $("btn-save-play"); + if (btnPlay) btnPlay.hidden = true; const btnInst = $("btn-save-instance"); if (btnInst) btnInst.hidden = true; - $("btn-export").disabled = true; + const btnExport = $("btn-export"); + if (btnExport) btnExport.disabled = true; $("message-feed").innerHTML = `

点击左侧 + 新建作品

`; const stage = $("workspace-stage"); if (stage) { @@ -1338,7 +1437,8 @@ function renderEmpty() { resetRailChrome(); hideBookMenu(); document.body.dataset.lifecycle = "design"; - $("composer").innerHTML = `
暂无打开的作品
`; + const composerMain = $("composer-main"); + if (composerMain) composerMain.innerHTML = `
暂无打开的作品
`; renderBookList(); } @@ -1349,6 +1449,31 @@ function stopLivePoll() { } } +function beginUiRequest() { + return ++inflightSeq; +} + +function isStaleUiRequest(seq) { + return seq !== inflightSeq; +} + +async function retryRun() { + if (!sessionId) return; + const seq = beginUiRequest(); + try { + renderSession(lastView, true); + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, { + method: "POST", + body: JSON.stringify({ action: "retry_run" }), + }); + if (isStaleUiRequest(seq)) return; + renderSession(view, false); + } catch (err) { + if (isStaleUiRequest(seq)) return; + if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + } +} + function startLivePoll() { stopLivePoll(); if (!sessionId) return; @@ -1390,9 +1515,15 @@ function renderSession(view, loading = false) { onEditMessage: (messageId, text) => messageAction("edit", messageId, { text }), onRefreshMessage: (messageId) => messageAction("refresh", messageId), onDeleteMessage: (messageId) => messageAction("delete", messageId), + onRollbackToInput: (messageId, draft) => { + pendingComposerDraft = String(draft ?? ""); + pendingComposerDraftSeq = 0; + messageAction("delete", messageId); + }, onViewContext: (messageId) => showContextTraceDialog(messageId), onSwitchVariant: (messageId, direction) => messageAction("variant", messageId, { direction }), + onRetryRun: () => retryRun(), }); renderComposer(view, loading); if (loading) startLivePoll(); @@ -1401,6 +1532,13 @@ function renderSession(view, loading = false) { async function messageAction(kind, messageId, body = {}) { if (!sessionId) return; + const seq = beginUiRequest(); + if (pendingComposerDraft && pendingComposerDraftSeq === 0 && kind === "delete") { + pendingComposerDraftSeq = seq; + } else if (pendingComposerDraft && pendingComposerDraftSeq !== seq) { + pendingComposerDraft = ""; + pendingComposerDraftSeq = 0; + } try { renderSession(lastView, true); const view = await api( @@ -1410,8 +1548,12 @@ async function messageAction(kind, messageId, body = {}) { body: JSON.stringify(body), }, ); + if (isStaleUiRequest(seq)) return; renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; + pendingComposerDraft = ""; + pendingComposerDraftSeq = 0; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } } @@ -1448,6 +1590,7 @@ async function submitComposer(text, { acceptOnEmpty } = {}) { async function sendText(text) { if (!sessionId) return; + const seq = beginUiRequest(); const trimmed = text?.trim() ?? ""; const qHost = $("questions-card-host"); const activeQs = getActiveQuestions(lastView); @@ -1480,9 +1623,11 @@ async function sendText(text) { method: "POST", body: JSON.stringify(body), }); + if (isStaleUiRequest(seq)) return; clearQuestionCardState(qHost); renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; if (qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } @@ -1516,9 +1661,11 @@ async function sendText(text) { method: "POST", body: JSON.stringify(body), }); + if (isStaleUiRequest(seq)) return; clearQuestionCardState(qHost); renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; if (qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } @@ -1544,9 +1691,11 @@ async function sendText(text) { method: "POST", body: JSON.stringify({ text: "" }), }); + if (isStaleUiRequest(seq)) return; clearQuestionCardState(qHost); renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } } @@ -1558,9 +1707,11 @@ async function sendText(text) { method: "POST", body: JSON.stringify({ text: trimmed }), }); + if (isStaleUiRequest(seq)) return; clearQuestionCardState(qHost); renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; if (cardOpen && qHost) qHost._qDismissed = null; if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); } @@ -1568,6 +1719,7 @@ async function sendText(text) { async function runAction(action, extra = {}) { if (!sessionId) return; + const seq = beginUiRequest(); const qHost = $("questions-card-host"); try { // 接受 / 跳过:先收起询问卡,避免 loading 用旧 waitingReason 再画出来 @@ -1575,16 +1727,25 @@ async function runAction(action, extra = {}) { clearQuestionCardState(qHost, lastView); } renderSession(lastView, true); - const body = { action, ...extra }; + const payload = { action, ...extra }; + if (action === "accept") { + const picker = document.querySelector("[data-opening-picker]"); + if (picker) { + const n = Number(picker.getAttribute("data-selected") || 0); + if (Number.isFinite(n)) payload.openingIndex = n; + } + } const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/actions`, { method: "POST", - body: JSON.stringify(body), + body: JSON.stringify(payload), }); + if (isStaleUiRequest(seq)) return; if (action === "accept" || action === "skip_questions") { clearQuestionCardState(qHost); } renderSession(view, false); } catch (err) { + if (isStaleUiRequest(seq)) return; if ( (action === "accept" || action === "skip_questions") && qHost @@ -1596,7 +1757,17 @@ async function runAction(action, extra = {}) { } async function setLifecycle(stage) { - if (!sessionId || stage === lastView?.lifecycleStage) return; + if (!sessionId) return; + if (stage === lastView?.lifecycleStage) { + if (stage === "design") return; + if ( + stage === "play" && + lastView?.playLayerActive && + lastView?.waitingReason?.kind !== "review_artifact" + ) { + return; + } + } if (stage === "play" && !lastView?.playReady) { alert("请先验收 Worker 集(创作定稿)后再进入游玩"); return; @@ -1606,6 +1777,7 @@ async function setLifecycle(stage) { method: "POST", body: JSON.stringify({ stage }), }); + if (stage === "design") activePlaySaveId = null; renderSession(view, false); } catch (err) { alert(err.message || "无法切换阶段"); @@ -1617,6 +1789,7 @@ async function loadBooks() { const data = await api("/api/books"); books = data.books ?? []; renderBookList(); + for (const book of books) void fetchPlaySavesForBook(book.id); } function openNewBookDialog() { @@ -1692,7 +1865,7 @@ async function createBook() { $("dialog-new-book").close(); if (data.book) books.unshift(data.book); activeBookId = data.book?.id; - navigateToBook(data.book.id); + expandBook(data.book.id); renderSession(data.session, false); } catch (err) { alert(err.message); @@ -1703,6 +1876,7 @@ async function createBook() { async function openBook(bookId) { activeBookId = bookId; + expandBook(bookId); renderBookList(); if (lastView) renderSession(lastView, true); try { @@ -1725,8 +1899,9 @@ async function deleteBookById(bookId) { await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" }); books = books.filter((b) => b.id !== bookId); playSavesByBook.delete(bookId); + expandedBookIds.delete(bookId); selectedBookIds.delete(bookId); - if (sidebarNav.level === "book" && sidebarNav.bookId === bookId) sidebarNav = { level: "root" }; + if (activeBookId === bookId) activePlaySaveId = null; hideBookMenu(); if (activeBookId === bookId) { if (books.length) { @@ -1744,7 +1919,9 @@ async function deleteBookById(bookId) { async function saveCurrentPlay() { if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集"); - if (lastView.lifecycleStage !== "play") return alert("请先切换到「游玩」"); + if (lastView.lifecycleStage !== "play" || !lastView.playLayerActive) { + return alert("请先进入游玩"); + } const label = prompt("游玩进度存档名称"); if (!label?.trim()) return; try { @@ -1753,9 +1930,7 @@ async function saveCurrentPlay() { body: JSON.stringify({ label: label.trim(), sessionId, kind: "run" }), }); playSavesByBook.delete(activeBookId); - if (sidebarNav.level === "book" && sidebarNav.bookId === activeBookId) { - void fetchPlaySavesForBook(activeBookId, true); - } + void fetchPlaySavesForBook(activeBookId, true); } catch (err) { alert(err.message); } @@ -1771,9 +1946,7 @@ async function saveCurrentInstance() { body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }), }); playSavesByBook.delete(activeBookId); - if (sidebarNav.level === "book" && sidebarNav.bookId === activeBookId) { - void fetchPlaySavesForBook(activeBookId, true); - } + void fetchPlaySavesForBook(activeBookId, true); } catch (err) { alert(err.message); } @@ -1834,6 +2007,9 @@ function showContextTraceDialog(messageId) { `MODEL ${trace.model || usage?.model || "model?"}`, `CREATED ${trace.createdAt || subject.createdAt || "-"}`, `REQUEST ${trace.messages.length} messages / ${requestChars.toLocaleString("zh-CN")} chars`, + trace.generation && Object.keys(trace.generation).length + ? `GEN ${JSON.stringify(trace.generation)}` + : "GEN (none)", usage?.totalTokens ? `TOKENS total=${usage.totalTokens} cached=${usage.cachedTokens ?? 0} miss=${usage.cacheMissTokens ?? 0}` : "TOKENS not recorded", @@ -1868,8 +2044,16 @@ $("book-action-menu")?.addEventListener("click", (e) => { if (!bookId || !action) return; if (action === "open") { setBookSelectMode(false); - navigateToBook(bookId); - void openBook(bookId); + void openBookDesign(bookId); + } else if (action === "play") { + setBookSelectMode(false); + void openCurrentPlay(bookId); + } else if (action === "save-instance") { + if (bookId === activeBookId) void saveCurrentInstance(); + } else if (action === "save-play") { + if (bookId === activeBookId) void saveCurrentPlay(); + } else if (action === "export") { + if (bookId === activeBookId) exportSession(); } else if (action === "rename") { const id = selectedBookIds.size === 1 ? [...selectedBookIds][0] : bookId; void renameBookById(id); @@ -1903,13 +2087,18 @@ $("form-new-book")?.addEventListener("submit", (e) => { e.preventDefault(); createBook(); }); -$("lifecycle-toggle")?.addEventListener("click", (e) => { - const btn = e.target.closest("[data-stage]"); - if (!btn?.disabled) setLifecycle(btn.getAttribute("data-stage")); +$("btn-export")?.addEventListener("click", () => { + document.querySelector("details.more-menu")?.removeAttribute("open"); + exportSession(); +}); +$("btn-save-play")?.addEventListener("click", () => { + document.querySelector("details.more-menu")?.removeAttribute("open"); + void saveCurrentPlay(); +}); +$("btn-save-instance")?.addEventListener("click", () => { + document.querySelector("details.more-menu")?.removeAttribute("open"); + void saveCurrentInstance(); }); -$("btn-export")?.addEventListener("click", exportSession); -$("btn-save-play")?.addEventListener("click", () => void saveCurrentPlay()); -$("btn-save-instance")?.addEventListener("click", () => void saveCurrentInstance()); window.addEventListener("wa:session-updated", (e) => { const view = e.detail; @@ -1927,10 +2116,10 @@ async function init() { void populateDirectorSelect(); await loadBooks(); if (books.length) { - sidebarNav = { level: "root" }; await openBook(books[0].id); } else { renderEmpty(); + openNewBookDialog(); } } catch { $("status-text").textContent = "加载失败"; diff --git a/web/display-labels.js b/web/display-labels.js index fe030d1..54effb9 100644 --- a/web/display-labels.js +++ b/web/display-labels.js @@ -101,6 +101,10 @@ export function formatWorkerDisplayTitle(workerId, action = null) { export function reviewComposerCopy(workerId, opts = {}) { const hasQuestions = Boolean(opts.hasQuestions); const id = String(workerId ?? "").trim(); + const openingReview = + id === "opening-generator" || + (Array.isArray(opts.outputTags) && + opts.outputTags.includes("设计.开场白与开场变量")); if (id === "design-flow") { return { @@ -120,7 +124,7 @@ export function reviewComposerCopy(workerId, opts = {}) { }; } - if (id === "opening-generator") { + if (id === "opening-generator" || openingReview) { return { chip: "开场", kicker: "待选定", diff --git a/web/index.html b/web/index.html index 326b688..b7edf9e 100644 --- a/web/index.html +++ b/web/index.html @@ -35,7 +35,6 @@
-
@@ -48,39 +47,11 @@
-
-
-

未打开作品

- -
-
-
- - -
-
-
- - - - — -
- 更多 - -
-
-
-
-

点击左侧 + 新建作品

+

正在打开作品…

@@ -106,7 +77,22 @@
-
+
@@ -133,7 +119,11 @@
-
${dismissNote} @@ -414,40 +420,21 @@ export function renderQuestionsCard(host, view, _handlers = {}) { .join("")}
`; - const setExpanded = (next) => { - state.expanded = next; - host.classList.toggle("is-expanded", next); + const setCollapsed = (next) => { + state.collapsed = next; + host.classList.toggle("is-collapsed", next); const top = host.querySelector("[data-qtop]"); if (top) { - top.title = next ? "点击收起" : "点击展开"; - top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡"); + top.title = next ? "展开追问" : "折叠追问"; + top.setAttribute("aria-expanded", next ? "false" : "true"); + top.setAttribute("aria-label", next ? "展开追问" : "折叠追问"); } }; host.querySelector("[data-qtop]")?.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - setExpanded(!state.expanded); - }); - - host.querySelector(".qcard-body")?.addEventListener("click", (e) => { - const t = e.target; - if (!(t instanceof Element)) return; - if ( - t.closest( - ".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager", - ) - ) { - return; - } - // 展开后点题干 / 评估区收起;收起时点内容区展开 - if (state.expanded) { - if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) { - setExpanded(false); - } - return; - } - setExpanded(true); + setCollapsed(!state.collapsed); }); host.querySelector("[data-qskip]")?.addEventListener("click", () => { @@ -488,7 +475,6 @@ export function renderQuestionsCard(host, view, _handlers = {}) { section.querySelectorAll(".qcard-opt").forEach((li) => { li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid); }); - if (!state.expanded) setExpanded(true); _handlers.onAnswersChange?.(); }); }); diff --git a/web/settings.css b/web/settings.css index 9e44c86..60d9338 100644 --- a/web/settings.css +++ b/web/settings.css @@ -800,6 +800,226 @@ code { background: rgba(24, 24, 27, 0.4); } +/* —— Preset probe —— */ +.probe-dialog { + border: none; + border-radius: 10px; + padding: 0; + background: var(--st-surface); + color: var(--st-text); + width: min(1080px, calc(100% - 24px)); + height: min(780px, calc(100% - 24px)); + max-width: none; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18); +} + +.probe-dialog[open] { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.probe-dialog::backdrop { + background: rgba(24, 24, 27, 0.45); +} + +.probe-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px; + border-bottom: 1px solid var(--st-border); + flex-shrink: 0; +} + +.probe-head h3 { + margin: 0; + font-size: 16px; + font-weight: 650; +} + +.probe-sub { + margin: 4px 0 0; + font-size: 12px; + color: var(--st-muted); +} + +.probe-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); +} + +.probe-composer, +.probe-inspect { + min-height: 0; + overflow: auto; + padding: 16px 18px 20px; +} + +.probe-composer { + border-right: 1px solid var(--st-border); + display: flex; + flex-direction: column; + gap: 12px; +} + +.probe-composer label, +.probe-extras label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--st-muted); + font-weight: 500; +} + +.probe-composer textarea, +.probe-extras textarea { + width: 100%; + resize: vertical; + padding: 9px 12px; + border-radius: 6px; + border: 1px solid var(--st-border-strong); + background: var(--st-bg); + color: var(--st-text); + font-size: 13px; + line-height: 1.5; +} + +.probe-composer textarea:focus, +.probe-extras textarea:focus { + outline: none; + border-color: #93c5fd; + box-shadow: 0 0 0 3px var(--st-accent-soft); + background: var(--st-surface); +} + +.probe-extras { + border: 1px solid var(--st-border); + border-radius: 8px; + padding: 8px 10px 10px; + background: var(--st-bg); +} + +.probe-extras summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--st-text); + margin-bottom: 8px; +} + +.probe-extras[open] summary { + margin-bottom: 10px; +} + +.probe-composer-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: auto; +} + +.probe-status { + margin: 0; + font-size: 12px; + color: var(--st-muted); +} + +.probe-inspect h4 { + margin: 0 0 10px; + font-size: 12px; + font-weight: 650; + color: var(--st-muted); + letter-spacing: 0.02em; +} + +.probe-empty { + margin: 8px 0; + color: var(--st-faint); + font-size: 13px; +} + +.probe-messages { + display: flex; + flex-direction: column; + gap: 10px; +} + +.probe-msg { + border: 1px solid var(--st-border); + border-radius: 8px; + background: var(--st-bg); + overflow: hidden; +} + +.probe-msg header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--st-border); + background: var(--st-surface); +} + +.probe-role { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--st-accent); +} + +.probe-role.user { + color: #0f766e; +} + +.probe-role.assistant { + color: #7c3aed; +} + +.probe-index { + font-size: 11px; + color: var(--st-faint); +} + +.probe-msg pre, +.probe-reply pre { + margin: 0; + padding: 10px 12px; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 12px; + line-height: 1.55; +} + +.probe-reply { + margin-top: 18px; +} + +@media (max-width: 800px) { + .probe-dialog { + height: calc(100% - 16px); + } + + .probe-body { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } + + .probe-composer { + border-right: none; + border-bottom: 1px solid var(--st-border); + max-height: 42vh; + } +} + .profile-dialog form { padding: 22px; display: flex; diff --git a/web/settings.html b/web/settings.html index e67d846..869dff8 100644 --- a/web/settings.html +++ b/web/settings.html @@ -78,6 +78,57 @@
+ +
+
+

试跑预设

+

+
+ +
+
+
+ +
+ 试跑用设定 / 历史 + + + + +
+
+ + +
+
+
+

发出的上下文

+
+

发送后显示实际拼进请求的消息。

+
+ +
+
+
+

新增 API

diff --git a/web/settings.js b/web/settings.js index 6dbeecd..09c8fb2 100644 --- a/web/settings.js +++ b/web/settings.js @@ -28,6 +28,29 @@ const sectionStorageEl = document.getElementById("section-storage"); const activeSettingsBarEl = document.getElementById("active-settings-bar"); const settingsToastEl = document.getElementById("settings-toast"); const contextTraceKeepEl = document.getElementById("context-trace-keep"); +const probeDialog = document.getElementById("preset-probe-dialog"); +const probePresetNameEl = document.getElementById("probe-preset-name"); +const probeMessageEl = document.getElementById("probe-message"); +const probeLoreBeforeEl = document.getElementById("probe-lore-before"); +const probeHistoryEl = document.getElementById("probe-history"); +const probeLoreAfterEl = document.getElementById("probe-lore-after"); +const probePostTurnEl = document.getElementById("probe-post-turn"); +const probeSendEl = document.getElementById("probe-send"); +const probeStatusEl = document.getElementById("probe-status"); +const probeMessagesEl = document.getElementById("probe-messages"); +const probeReplyWrapEl = document.getElementById("probe-reply-wrap"); +const probeReplyEl = document.getElementById("probe-reply"); + +const DEFAULT_PROBE_CONTEXT = { + message: "我推开门。", + loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。", + history: "店员:今晚只剩阁楼那间。\n你:好。", + loreAfter: "时间:深夜。地点:旅馆大厅。", + postTurn: "", +}; + +let probingPresetId = null; +let probeBusy = false; async function api(path, options = {}) { const res = await fetch(path, { @@ -192,6 +215,7 @@ function renderPresets() {
${activateButtonHtml(active, p.id, "activate-preset")} + @@ -476,6 +500,8 @@ presetsListEl.addEventListener("click", async (e) => { try { if (action === "activate-preset") { await activatePreset(id); + } else if (action === "probe-preset") { + openProbeDialog(id); } else if (action === "toggle-preset-entries") { await togglePresetEntries(id); } else if (action === "delete-preset") { @@ -573,6 +599,120 @@ presetFileEl.addEventListener("change", async () => { } }); +function resetProbeForm() { + probeMessageEl.value = DEFAULT_PROBE_CONTEXT.message; + probeLoreBeforeEl.value = DEFAULT_PROBE_CONTEXT.loreBefore; + probeHistoryEl.value = DEFAULT_PROBE_CONTEXT.history; + probeLoreAfterEl.value = DEFAULT_PROBE_CONTEXT.loreAfter; + probePostTurnEl.value = DEFAULT_PROBE_CONTEXT.postTurn; + probeStatusEl.hidden = true; + probeStatusEl.textContent = ""; + probeReplyWrapEl.hidden = true; + probeReplyEl.textContent = ""; + probeMessagesEl.innerHTML = + '

发送后显示实际拼进请求的消息。

'; +} + +function openProbeDialog(presetId) { + const preset = state.presets.find((p) => p.id === presetId); + probingPresetId = presetId; + probePresetNameEl.textContent = preset?.name ?? presetId; + resetProbeForm(); + probeDialog.showModal(); + probeMessageEl.focus(); + probeMessageEl.select(); +} + +function renderProbeMessages(messages) { + if (!messages?.length) { + probeMessagesEl.innerHTML = + '

这次没有拼出任何消息。

'; + return; + } + probeMessagesEl.innerHTML = messages + .map( + (m, i) => ` +
+
+ ${escapeHtml(m.role)} + #${i + 1} +
+
${escapeHtml(m.content)}
+
`, + ) + .join(""); +} + +function setProbeBusy(busy) { + probeBusy = busy; + probeSendEl.disabled = busy; + probeSendEl.textContent = busy ? "发送中…" : "发送一轮"; +} + +async function sendProbe() { + if (probeBusy || !probingPresetId) return; + const message = probeMessageEl.value.trim(); + if (!message) { + probeStatusEl.hidden = false; + probeStatusEl.textContent = "请填写本轮输入"; + probeMessageEl.focus(); + return; + } + + setProbeBusy(true); + probeStatusEl.hidden = false; + probeStatusEl.textContent = "正在拼装上下文…"; + probeReplyWrapEl.hidden = true; + + try { + const result = await api(`/api/presets/${probingPresetId}/probe`, { + method: "POST", + body: JSON.stringify({ + message, + loreBefore: probeLoreBeforeEl.value, + history: probeHistoryEl.value, + loreAfter: probeLoreAfterEl.value, + postTurn: probePostTurnEl.value, + }), + }); + renderProbeMessages(result.messages); + if (result.reply) { + probeReplyWrapEl.hidden = false; + probeReplyEl.textContent = result.reasoning + ? `${result.reasoning}\n\n——\n\n${result.reply}` + : result.reply; + } + if (result.error) { + probeStatusEl.textContent = `上下文已拼装,生成失败:${result.error}`; + } else if (result.skipReason) { + probeStatusEl.textContent = result.skipReason; + } else if (result.completed) { + probeStatusEl.textContent = `已发送 ${result.messages.length} 条`; + } else { + probeStatusEl.textContent = `已拼装 ${result.messages.length} 条`; + } + } catch (err) { + probeStatusEl.textContent = err.message; + } finally { + setProbeBusy(false); + } +} + +document.getElementById("probe-close")?.addEventListener("click", () => { + probeDialog.close(); +}); + +probeSendEl?.addEventListener("click", () => { + sendProbe().catch((err) => showToast(err.message, true)); +}); + +probeMessageEl?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + sendProbe().catch((err) => showToast(err.message, true)); + } +}); + document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => { try { const n = Number(contextTraceKeepEl?.value ?? 5); diff --git a/web/shells.html b/web/shells.html index 1395e72..810b7c9 100644 --- a/web/shells.html +++ b/web/shells.html @@ -32,7 +32,7 @@

按「要同时看见什么」选,不是按好看选。

- 主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏。 + 主任务永远是读正文(常 500~2000 字)。侧栏只换壳;长度/语气在预览顶栏。

diff --git a/web/shells.js b/web/shells.js index 8f0100e..34c415c 100644 --- a/web/shells.js +++ b/web/shells.js @@ -1,6 +1,6 @@ /** * 呈现壳预览:主工作面 = 壳画布。 - * 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动。 + * 按「要同时看见什么」选壳;正文按 500~2000 字撑高滚动。 */ import { PRESENT_SHELL_IDS, @@ -56,7 +56,7 @@ const TONES = [ const LENGTHS = [ { id: "short", label: "短" }, { id: "mid", label: "中 ·800" }, - { id: "long", label: "长 ·2500" }, + { id: "long", label: "长 ·2000" }, ]; const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。 diff --git a/web/styles.css b/web/styles.css index ffba014..58258da 100644 --- a/web/styles.css +++ b/web/styles.css @@ -18,8 +18,8 @@ --workspace-pad-x: clamp(24px, 4.5vw, 72px); --workspace-pad-y: clamp(20px, 3vh, 40px); --head-h: 48px; - --composer-min: 56px; - --composer-control-h: 40px; + --composer-min: 0px; + --composer-control-h: 36px; --font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif; --font-read: "Noto Serif SC", "Songti SC", "STSong", "Source Han Serif SC", Georgia, serif; font-family: var(--font-ui); @@ -201,7 +201,7 @@ body.rail-collapsed .panel-rail { align-items: stretch; } /* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */ .panel-main { display: grid; - grid-template-rows: var(--head-h) minmax(0, 1fr) auto; + grid-template-rows: minmax(0, 1fr) auto; min-width: 0; min-height: 0; height: 100%; @@ -435,7 +435,7 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle { padding: 8px; display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .coord-empty { margin: 16px 8px; @@ -443,6 +443,55 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle { color: var(--muted); text-align: center; } +.coord-pack { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 4px 6px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + min-width: 0; + cursor: context-menu; +} +.coord-pack:hover { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); + background: color-mix(in srgb, var(--accent-soft) 28%, var(--bg)); +} +.coord-pack--pending { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); +} +.coord-pack-head { + display: flex; + align-items: center; + min-height: 24px; + padding: 2px 28px 2px 8px; +} +.coord-pack-meta { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--muted); +} +.coord-pack--pending .coord-pack-meta { + color: var(--accent); +} +.coord-pack > .coord-line-menu { + top: 4px; + right: 4px; +} +.coord-pack:hover > .coord-line-menu, +.coord-pack:focus-within > .coord-line-menu { + opacity: 1; +} +.coord-pack .coord-line { + background: transparent; + padding: 6px 8px; +} +.coord-pack .coord-line:hover { + background: color-mix(in srgb, var(--accent-soft) 35%, transparent); +} .coord-line { position: relative; display: flex; @@ -565,6 +614,10 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle { color: var(--accent); font-weight: 700; } +.coord-context-request { + color: var(--text); + font-weight: 600; +} .coord-trace-meta { display: flex; flex-wrap: wrap; @@ -596,6 +649,14 @@ body.design-workspace .workspace-pending { margin-bottom: 6px; font-size: 12px; } +.workspace-pending-head .msg-action { + margin-left: auto; +} +.msg-live-empty { + margin: 0; + color: var(--muted); + font-size: 13px; +} body.is-reviewing .message-feed { flex: 0 1 auto; @@ -752,6 +813,33 @@ body.design-workspace[data-design-surface="answer"] .questions-card-host { margin-top: 0; padding: 0; } +/* 答题/验收工作面已是 surface,询问不再套圆角卡片 */ +body.design-workspace[data-design-surface="answer"] .questions-card-host.is-open:not([hidden]), +.workspace-review-ask .questions-card-host.is-open:not([hidden]) { + margin: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; +} +body.design-workspace[data-design-surface="answer"] .qcard-body, +.workspace-review-ask .qcard-body { + padding-left: 0; + padding-right: 0; +} +body.design-workspace[data-design-surface="answer"] .qcard-top, +.workspace-review-ask .qcard-top, +body.design-workspace[data-design-surface="answer"] .questions-card-host.is-collapsed .qcard-top, +.workspace-review-ask .questions-card-host.is-collapsed .qcard-top { + padding-left: 0; + padding-right: 0; +} +body.design-workspace[data-design-surface="answer"] .qcard-skip, +.workspace-review-ask .qcard-skip, +body.design-workspace[data-design-surface="answer"] .qcard-pager, +.workspace-review-ask .qcard-pager { + right: 0; +} body.design-workspace[data-design-surface="speak"] .workspace-surface-kicker { color: var(--accent); } @@ -766,25 +854,23 @@ body.design-workspace[data-design-surface="review"][data-review-kind="opening"] color: var(--accent); } -/* 挂载追问嵌在产物验收面下方,与产物同一工作面 */ +/* 挂载追问嵌在产物验收面下方:与产物同宽,随主柱动态适应 */ .workspace-review-ask { flex: 0 0 auto; margin-top: 0; min-height: 0; - max-width: var(--workspace-max, 920px); width: 100%; - align-self: center; - padding: 0 var(--workspace-pad-x, 24px) var(--workspace-pad-y, 16px); + max-width: none; + align-self: stretch; + padding: 8px var(--workspace-pad-x, 24px) var(--workspace-pad-y, 16px); box-sizing: border-box; + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); } .workspace-review-ask .questions-card-host { padding-left: 0; padding-right: 0; border-top: none; } -.workspace-review-ask .questions-card-host.is-open:not([hidden]) { - margin-top: 0; -} body.design-workspace .ws-creation-flow h4 { display: none; } body.design-workspace .flow-brief { @@ -1058,6 +1144,32 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { border-radius: 6px; min-width: 0; } +.explorer-row.has-twist { + grid-template-columns: 16px minmax(0, 1fr) auto; +} +.explorer-twist { + grid-column: 1; + grid-row: 1 / span 2; + align-self: center; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + line-height: 1; + color: var(--muted); + user-select: none; +} +.explorer-row.has-twist .explorer-main { grid-column: 2; } +.explorer-row.has-twist .explorer-del { grid-column: 3; } +.explorer-row.child { + padding-left: 28px; + min-height: 28px; + padding-top: 5px; + padding-bottom: 5px; +} +.explorer-row.child .explorer-name { font-size: 12px; font-weight: 400; } .explorer-row:hover { background: var(--bg); } .explorer-row.active { background: var(--accent-soft); } .explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; } @@ -1119,7 +1231,7 @@ body.design-workspace .flow-step[data-run="done"] .flow-name { color: var(--muted); letter-spacing: 0.04em; } .explorer-hint { - margin: 4px 8px; font-size: 11px; color: var(--muted); line-height: 1.4; + margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4; } .sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; } @@ -1214,6 +1326,9 @@ body.rail-collapsed .message-feed { font-size: 11px; flex-wrap: wrap; } +.msg-head [data-act="retry_run"] { + margin-left: auto; +} .msg-foot { display: flex; gap: 8px; @@ -1339,6 +1454,41 @@ body.rail-collapsed .message-feed { max-height: 240px; overflow: auto; } +.msg-play-process-row { + margin: 4px 0; +} +.msg-play-process { + font-size: 12px; + color: var(--muted); + border: 1px dashed var(--border); + border-radius: 6px; + background: #fafafa; +} +.msg-play-process > summary { + cursor: pointer; + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 10px; + user-select: none; + list-style: none; +} +.msg-play-process > summary::-webkit-details-marker { display: none; } +.msg-play-process > summary::before { + content: "▸"; + font-size: 10px; + color: var(--muted); +} +.msg-play-process[open] > summary::before { content: "▾"; } +.msg-play-process .msg-tag { + font-weight: 600; + color: var(--muted); +} +.msg-play-process .msg-body { + padding: 0 10px 10px; + max-height: 320px; + overflow: auto; +} .msg-live-indicator { margin-left: 8px; @@ -1519,7 +1669,7 @@ body.rail-collapsed .message-feed { position: absolute; right: 0; top: calc(100% + 4px); - z-index: 40; + z-index: 80; min-width: 132px; padding: 4px; border: 1px solid var(--border); @@ -1530,13 +1680,28 @@ body.rail-collapsed .message-feed { flex-direction: column; gap: 1px; } +.composer .more-menu-panel { + top: auto; + bottom: calc(100% + 4px); +} .more-menu-item { display: block; + width: 100%; padding: 7px 10px; + border: none; border-radius: 6px; + background: transparent; color: var(--text); text-decoration: none; + text-align: left; + font: inherit; font-size: 13px; + cursor: pointer; +} +.more-menu-item[hidden] { display: none !important; } +.more-menu-item:disabled { + opacity: 0.45; + cursor: not-allowed; } .more-menu-item:hover { background: var(--bg); @@ -1567,27 +1732,50 @@ body.rail-collapsed .message-feed { .msg-q-list li { margin: 0.35rem 0; } .msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; } -/* 游玩:阅读柱略抬权重 */ +/* 游玩:每轮终稿仍是气泡(方便扫上下文),正文在气泡里撑高 */ body[data-lifecycle="play"] .message-feed { - padding: 14px 18px 18px; - gap: 14px; + padding: 16px 18px 22px; + gap: 16px; } body[data-lifecycle="play"] .msg-assistant-row .msg-bubble { width: min(100%, 44rem); max-width: 100%; border-color: color-mix(in srgb, var(--border) 70%, transparent); - box-shadow: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } body[data-lifecycle="play"] .msg-assistant-row .msg-head { padding-top: 6px; } -body[data-lifecycle="play"] .present-shell { - border-radius: 10px; -} body[data-lifecycle="play"] .msg-user-row .msg-bubble { width: min(100%, 28rem); max-width: 90%; } +body[data-lifecycle="play"] .msg-play-final .msg-bubble { + width: min(100%, 46rem); +} +body[data-lifecycle="play"] .msg-play-final .present-shell { + border: none; + box-shadow: none; + background: transparent; + padding: 0; + border-radius: 0; +} +body[data-lifecycle="play"] .msg-play-final .present-shell-badge { + display: none; +} +body[data-lifecycle="play"] .msg-play-final .present-stage .present-region--body .present-region-body, +body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body { + font-size: 18.5px; + line-height: 1.88; + max-width: none; + max-height: none; + overflow: visible; +} +body[data-lifecycle="play"] .msg-play-final .present-shell--chat_monitor .present-stage { + padding: 0; + border: none; + background: transparent; +} /* 产物紧凑卡:横向主轴,预览一行 */ .artifact-compact-list { @@ -2455,7 +2643,8 @@ body.design-workspace .af-mosaic { color: var(--text); line-height: 1.45; } -.propose-sync-hint { +.propose-sync-hint, +.propose-plan-hint { margin: 0; font-size: 12px; color: var(--muted); @@ -2544,6 +2733,9 @@ body.design-workspace .af-mosaic { font-size: 12px; font-weight: 400; } +.flow-occ-revise { + color: var(--text, #222); +} .flow-order { grid-row: 1 / -1; align-self: center; @@ -2685,10 +2877,9 @@ body.design-workspace .af-mosaic { box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #2a6) 20%, transparent); } -/* 询问卡:host 本身即卡片(不再套内层 .qcard) */ +/* 询问卡:对话流里 host 即卡片;创作工作面上剥壳,见上方 flatten 规则 */ .questions-card-host { --qcard-max: 48vh; - --qcard-max-expanded: 82vh; flex: 0 1 auto; width: 100%; box-sizing: border-box; @@ -2710,16 +2901,17 @@ body.design-workspace .af-mosaic { border: 1px solid var(--border); border-radius: 10px; background: var(--surface, #fff); - transition: max-height 0.22s ease; + transition: max-height 0.18s ease; } -.questions-card-host.is-open.is-expanded:not([hidden]) { - max-height: var(--qcard-max-expanded); +.questions-card-host.is-open.is-collapsed:not([hidden]) { + max-height: none; } .qcard-top { flex: 0 0 auto; display: flex; align-items: center; justify-content: center; + gap: 10px; min-height: 18px; margin: 0; padding: 4px 12px 0; @@ -2734,18 +2926,40 @@ body.design-workspace .af-mosaic { .qcard-top-grip { width: 28px; height: 3px; + flex-shrink: 0; border-radius: 999px; background: color-mix(in srgb, var(--muted, #888) 35%, transparent); } -.questions-card-host.is-expanded .qcard-top { - min-height: 22px; - padding-top: 6px; -} -.questions-card-host.is-expanded .qcard-top:hover .qcard-top-grip { +.qcard-top:hover .qcard-top-grip { background: color-mix(in srgb, var(--muted, #888) 55%, transparent); } -.questions-card-host:not(.is-expanded) .qcard-body { - cursor: pointer; +.qcard-collapsed-meta { + display: none; + min-width: 0; + flex: 1; + font-size: 12px; + font-weight: 550; + line-height: 1.35; + color: var(--text); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.questions-card-host.is-collapsed .qcard-top { + min-height: 44px; + padding: 0 12px; + justify-content: flex-start; +} +.questions-card-host.is-collapsed .qcard-top:hover .qcard-top-grip { + background: color-mix(in srgb, var(--muted, #888) 55%, transparent); +} +.questions-card-host.is-collapsed .qcard-collapsed-meta { + display: block; +} +.questions-card-host.is-collapsed .qcard-body, +.questions-card-host.is-collapsed .qcard-float { + display: none; } /* 悬浮控件层:不占文档流高度 */ .qcard-float { @@ -3472,6 +3686,7 @@ body.design-workspace .af-mosaic { border: none; background: transparent; padding: 8px 12px; font-size: 13px; cursor: pointer; border-radius: 4px; } +.context-menu-item[hidden] { display: none; } .context-menu-item:hover { background: var(--bg); } .context-menu-separator-before { border-top: 1px solid var(--border); } .context-menu-danger { color: var(--danger); } @@ -3491,18 +3706,46 @@ body.design-workspace .af-mosaic { .timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); } .timeline-empty { color: var(--muted); } -/* Composer — 单层输入壳;底栏本身不再套第二层卡片 */ +/* Composer — 单行:状态 / 输入 / 更多;菜单向上弹出不被裁切 */ .composer { border-top: 1px solid var(--border); - padding: 10px var(--workspace-pad-x, 16px) 12px; + padding: 6px var(--workspace-pad-x, 16px) 8px; min-height: var(--composer-min); - max-height: min(50vh, 420px); - overflow: hidden; + overflow: visible; background: var(--bg); flex-shrink: 0; + position: relative; + z-index: 20; display: flex; - flex-direction: column; - justify-content: flex-end; + flex-direction: row; + align-items: flex-end; + gap: 8px; +} +.composer > .status { + flex: 0 0 auto; + align-self: center; + min-width: 0; +} +.composer:has(.composer-mode-chip) > .status { + display: none; +} +#composer-main { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + max-height: min(50vh, 420px); + overflow: auto; +} +.composer > .more-menu { + flex: 0 0 auto; + align-self: flex-end; + margin-bottom: 2px; +} +.composer .more-menu-sum { + height: calc(var(--composer-control-h) - 8px); + display: inline-flex; + align-items: center; + box-sizing: border-box; } /* 唯一描边:输入 + 操作同框 */ .composer-input-shell { @@ -3584,7 +3827,7 @@ body.design-workspace .af-mosaic { overflow-y: hidden; border: none; border-radius: 0; - padding: 8px 4px; + padding: 6px 4px; line-height: 1.5; background: transparent; box-shadow: none; @@ -3603,7 +3846,16 @@ body.design-workspace .af-mosaic { margin-right: 2px; } .composer-waiting, .composer-idle { - padding: 12px; text-align: center; color: var(--muted); font-size: 13px; + padding: 6px 0; text-align: left; color: var(--muted); font-size: 13px; +} +.composer-waiting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.composer-waiting .btn { + flex-shrink: 0; } .composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); } .composer-form { @@ -3656,7 +3908,7 @@ body.design-workspace .af-mosaic { overflow-y: hidden; border: none; border-radius: 0; - padding: 8px 4px; + padding: 6px 4px; line-height: 1.5; background: transparent; field-sizing: content; @@ -3666,7 +3918,7 @@ body.design-workspace .af-mosaic { border: none; box-shadow: none; } -.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } +.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0; } .composer-actions .btn-primary { flex: 1; } /* Intake (from intake-ui.js) */ @@ -3729,7 +3981,7 @@ body.design-workspace .af-mosaic { } /* —— 游玩呈现壳 present.v1 —— - * 主工作面 = body(随 600~3000 字撑高)。 + * 主工作面 = body(随 500~2000 字撑高)。 * 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。 */ .present-shell { @@ -3793,7 +4045,7 @@ body.design-workspace .af-mosaic { white-space: pre-wrap; font-size: 14px; } -/* P0:正文舞台 —— 随字数伸展(600~3000 靠外层滚动,不塞进小框) */ +/* P0:正文舞台 —— 随字数伸展(500~2000 靠外层滚动,不塞进小框) */ .present-stage { flex: 1 1 auto; min-height: 0; @@ -4085,3 +4337,37 @@ body.design-workspace .af-mosaic { color: #e7e5e4; background: #3f3f46; } + +.opening-setup-body { + display: grid; + gap: 12px; +} +.opening-picker { + display: grid; + gap: 8px; +} +.opening-picker-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} +.opening-picker-btn { + font: inherit; + font-size: 12px; + padding: 4px 10px; + border: 1px solid var(--border); + background: var(--bg-elev, transparent); + color: inherit; + border-radius: 6px; + cursor: pointer; +} +.opening-picker-btn:hover { + border-color: var(--accent); +} +.opening-picker-pos { + font-size: 12px; + color: var(--muted, #888); + min-width: 4.5em; + text-align: center; +}