From dd0a9e87d8086d3629ba8ea628a95cf8a80ec519 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Tue, 22 Sep 2026 07:40:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E4=B8=BB=E8=A7=92=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E3=80=81=E5=BC=80=E5=9C=BA=E5=B0=81=E5=8D=B0=E4=B8=8E?= =?UTF-8?q?=E6=B8=B8=E7=8E=A9=E8=BE=85=E5=8A=A9=EF=BC=8C=E8=AE=A9=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E6=A8=A1=E6=8B=9F=E5=99=A8=E4=BB=8E=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E8=90=BD=E5=88=B0=E6=9C=AC=E5=B1=80=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 | 1 + docs/briefs/capability-authoring-brief.md | 13 +- docs/briefs/prompt-revision-inventory.md | 4 +- docs/context-fragment-design.md | 5 +- docs/creation-playbook.md | 2 +- docs/daily-use-p0.md | 7 +- docs/play-presentation-shells.md | 1 + docs/progressive-data-design.md | 2 +- docs/ui-glossary.md | 54 +- docs/world-simulator-modules.md | 14 +- .../modules/aesthetics-interaction/prompt.md | 5 +- .../world-simulator/modules/catalog.yaml | 29 +- .../modules/context-order/prompt.md | 2 +- .../modules/generation-rules/prompt.md | 16 +- .../modules/mechanism/prompt.md | 6 +- .../modules/narrative-guide/prompt.md | 160 +++ .../modules/narrative/prompt.md | 4 +- .../modules/opening-setup/prompt.md | 134 ++- .../modules/protagonist/prompt.md | 122 ++ .../world-simulator/modules/refine/prompt.md | 2 +- .../modules/reply-format/prompt.md | 2 +- .../modules/status-bar/prompt.md | 2 +- .../modules/story-progression/prompt.md | 134 +++ .../modules/topology/prompt.md | 4 +- .../modules/worker-spec/prompt.md | 4 +- .../modules/world-blueprint/prompt.md | 10 +- .../world-simulator/recipes/catalog.yaml | 6 +- .../recipes/world-simulator/recipe.yaml | 6 +- .../world-simulator/recipes/转述/catalog.yaml | 7 +- .../recipes/转述/交互式长文生成器/recipe.yaml | 6 +- .../recipes/转述/快穿短局/recipe.yaml | 36 + .../recipes/转述/数据化跑团体验/recipe.yaml | 11 +- .../recipes/转述/文本生成器/recipe.yaml | 15 +- .../worker-templates/narrator.yaml | 8 +- .../worker-templates/opening-generator.yaml | 4 +- .../worker-templates/world-simulator.yaml | 6 +- .../workers/opening-generator/SKILL.md | 8 +- src/blackboard/blackboard.ts | 14 + src/blackboard/table-cells.ts | 112 +- src/dictate/index.ts | 23 + src/dictate/insert-feedback.ts | 210 ++++ src/dictate/play-bind.ts | 16 +- src/dictate/prompt.ts | 75 +- src/dictate/repeatable-tags.ts | 217 ++++ src/dictate/tools.ts | 70 +- src/dictate/turn.ts | 190 ++- src/dictate/types.ts | 21 +- src/llm/client.ts | 2 + src/llm/request-log.ts | 191 +++ src/llm/token-tracker.ts | 96 +- src/log.ts | 16 + src/persona/protagonist-bind.ts | 238 ++++ src/runtime/driver.ts | 6 +- src/runtime/phase-runtime.ts | 73 +- src/server/book-handlers.ts | 18 +- src/server/display-labels.ts | 19 +- src/server/session-manager.ts | 251 +++- src/server/settings-handlers.ts | 5 +- src/server/web-server.ts | 83 +- src/skills/context-order.ts | 4 + src/skills/creation-flow.ts | 80 +- src/skills/opening-seal.ts | 271 ++++- src/skills/play-aux.ts | 117 ++ src/skills/present-packet.ts | 20 + src/skills/value-map.ts | 23 + src/skills/variable-catalog.ts | 51 +- src/skills/worker-set-view.ts | 3 + src/worker/executor.ts | 8 +- src/worker/gm-harness.ts | 6 +- tests/creation-flow.test.ts | 60 +- tests/dictate-play-bind.test.ts | 16 +- tests/dictate.test.ts | 480 +++++++- tests/display-labels.test.ts | 13 +- tests/opening-seal.test.ts | 112 ++ tests/play-aux.test.ts | 76 ++ tests/present-packet.test.ts | 14 + tests/protagonist-bind.test.ts | 193 ++++ tests/request-log.test.ts | 128 +++ tests/table-cells.test.ts | 28 + tests/value-map.test.ts | 25 + web/agent-ui.js | 726 ++++++++++-- web/app.js | 1021 +++++++++++++---- web/display-labels.js | 17 +- web/index.html | 68 +- web/play-aux.js | 283 +++++ web/present-shells.js | 11 +- web/styles.css | 913 +++++++++++++-- web/theme.js | 90 +- 88 files changed, 6869 insertions(+), 786 deletions(-) create mode 100644 skills/dialogue/world-simulator/modules/narrative-guide/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/protagonist/prompt.md create mode 100644 skills/dialogue/world-simulator/modules/story-progression/prompt.md create mode 100644 skills/dialogue/world-simulator/recipes/转述/快穿短局/recipe.yaml create mode 100644 src/dictate/insert-feedback.ts create mode 100644 src/dictate/repeatable-tags.ts create mode 100644 src/llm/request-log.ts create mode 100644 src/persona/protagonist-bind.ts create mode 100644 src/skills/play-aux.ts create mode 100644 tests/play-aux.test.ts create mode 100644 tests/protagonist-bind.test.ts create mode 100644 tests/request-log.test.ts create mode 100644 web/play-aux.js diff --git a/docs/briefs/artifact-card-coverage.md b/docs/briefs/artifact-card-coverage.md index ab559fa..dfe25f0 100644 --- a/docs/briefs/artifact-card-coverage.md +++ b/docs/briefs/artifact-card-coverage.md @@ -10,6 +10,7 @@ | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | | 具体实例 | 通用 fragment / 结构化 | context-fragment 外壳 | | 叙事指南与故事推进 | **专用 mosaic**(风格与写法 \| 推进与决策) | `renderNarrativeBodyHtml` | +| 叙事指南 / 故事推进 | 同上 mosaic(落盘拆开后各用半栏) | `renderNarrativeBodyHtml` | | 变量设计 | **结构化卡**(真值 / side_effects) | `renderVariableDesignHtml` | | 变量控制上下文 | 通用 fragment | context-fragment | | 正文组成 | **专用卡**:present 壳渲染 `正文.示例.灌数`(看美化)+ 契约折叠 | `renderReplyFormatBodyHtml` | diff --git a/docs/briefs/capability-authoring-brief.md b/docs/briefs/capability-authoring-brief.md index 50685c1..aff462a 100644 --- a/docs/briefs/capability-authoring-brief.md +++ b/docs/briefs/capability-authoring-brief.md @@ -139,13 +139,16 @@ **禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。 〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。 -## 2.5 已合并技能(勿拆回) +## 2.5 合并与按进料拆开 | 错误拆法 | 正确技能 | |----------|----------| | 「交互范式」+「美学纲领」两步 | **美学纲领与交互范式**(`aesthetics-interaction`)一步 | -询问相近则合并为一步;不要在新技能里建议再拆开。 +询问主题高度重叠、**调用条件相同** → 合成一个能力(如美学+交互)。 +调用条件不同则分技能。工序编排「叙事指南与故事推进」保持合并;对话落盘拆成「叙事指南」与「故事推进」(catalog `intake`)。 + +不要在美学步里建议再拆回交互/美学两步。 ## 2.6 命名纪律 @@ -200,7 +203,7 @@ catalog 登记(短声明给编排看;可选 repeatable) - **正推**:需要 [体验/产物] → 才纳入某技能;勿默认全选池内能力。调用条件写在该技能 `when` / `when_not`,不要写进规划 SKILL 或配方 process。 - **边界**:每个能力在 `meta.boundary` 写清「本步定什么 / 不定什么 / 交给谁」。 - **依赖**:由工作流计划 `depends_on` 声明;执行期程序注入对应产物。 -- **合并**:询问主题高度重叠 → 合成一个能力(如美学+交互)。 +- **合并**:询问主题高度重叠且调用条件相同 → 合成一个能力(如美学+交互)。调用条件不同则分技能(对话落盘「叙事指南」/「故事推进」)。 - **缩减**:每增必问能否删、能否常驻替代、能否合并。 ## 3.5 方法层硬禁止(写任何能力都适用) @@ -488,7 +491,9 @@ artifact: | 舞台骨架 | `world-blueprint` | 已写(`context-fragment.v1`;社会结构 + 世界状况) | | 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;合同严、正文可长) | | 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;只按规则执行) | -| 叙事指南与故事推进 | `narrative` | 已写(`context-fragment.v1`;遣词/笔墨/禁忌+推进;旧称叙事指南) | +| 叙事指南与故事推进 | `narrative` | 工序编排(`context-fragment.v1`;遣词/笔墨/禁忌+推进) | +| 叙事指南 | `narrative-guide` | 对话落盘;遣词/笔墨/禁忌/写法档 | +| 故事推进 | `story-progression` | 对话落盘;推进 + 用户输入用法 | | 正文组成 | `reply-format` | 已写(壳适配 + 示例 present 预览看美化;旧称设计回复格式) | | 设计监控栏 | `status-bar` | 已写(只盯会变信息;旧称设计状态栏) | | 开场白与开场变量 | `opening-setup` | 已写(〔收口〕:1~多条开场;选定后落库并保存定稿) | diff --git a/docs/briefs/prompt-revision-inventory.md b/docs/briefs/prompt-revision-inventory.md index 6095587..4509789 100644 --- a/docs/briefs/prompt-revision-inventory.md +++ b/docs/briefs/prompt-revision-inventory.md @@ -20,7 +20,9 @@ | 舞台骨架 | 会换区才备案;代入后几乎不移动、只在一处待着(网聊/门卫室)不排 | | 生成规则 | 时机 a 精准格式或时机 b 原创会雾且还要生成,满足一条就排 | | 具体实例 | 已有 seed 类规则才排;`runtime_only` 不排;本步不改合同(已审查,基本不动) | -| 叙事指南与故事推进 | 转述仍不知遣词/禁忌/推进才排;默认:长自然段;用户输入=大纲扩写不越界 | +| 叙事指南与故事推进 | 工序编排:转述仍不知遣词/禁忌/推进才排 | +| 叙事指南 | 对话落盘:缺怎么写(遣词/笔墨/场合换档)才排;推进已钉不构成不排 | +| 故事推进 | 对话落盘:缺输入用法或推进方式才排;文风未钉不构成不排 | | 拓扑图谱 | 必须生成且不适合走生成规则→实例;用拓扑结构描述(升级路径、地图、人物关系等) | | 变量设计与更新规则 | 必须跨轮记住否则会糊才排(已审查,用户确认完成) | | 变量控制上下文 | 有信息差才排;全员同可见则可不排(已审查,用户确认完成) | diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index f663a57..d51f0bb 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -60,13 +60,14 @@ 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 生成规则正文:必要性判断 + `rules[]`(设计判断 + 生成与描写含**配方/表现** + **分级表现** + **单层**产物格式 + 可选池:**方向池**以方向概括为主、例子极少,**元素池**仅封闭集合,旁观抽样只抽元素池 + **数量**与**一次生成**);自评=必要性/属性妥当/材料得当/格式准确。 美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。 -叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。 +叙事指南与故事推进(工序编排)正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层。artifact=`设计.叙事指南与故事推进`。 +对话落盘拆成两份:`设计.叙事指南`(遣词/笔墨/禁忌/写法档)与 `设计.故事推进`(推进 + 用户输入用法);各自双挂,条件独立落盘。 回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。 正文组成:把回复呈现清单展示给用户(选壳、美化,可写 CSS/HTML/JS);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。 变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。 变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。 生成规则:合同挂主世界层 + 旁观(旁观读 rule_id/格式/必要性,描写长文可 summary)。 -开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。 +开场白与开场变量:每条开场 = meta(用户角色+初值)+ 正文(present.v1,壳与正文组成一致);可经 opening-generator 落库。 具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。 diff --git a/docs/creation-playbook.md b/docs/creation-playbook.md index e932312..0b3038d 100644 --- a/docs/creation-playbook.md +++ b/docs/creation-playbook.md @@ -47,7 +47,7 @@ Book 跨 Session:过程、资产、游玩 固定上下文 tag 的主收益是 **跨执行单元复用同一份正文**;写下游时仍依赖上游定稿(不能只报 tag 名省掉正文)。能省的是扯皮过程(验收折叠)。 不再要求用户选择 skill 包;默认 orchestrator 见 `src/config/default-orchestrator.ts`。 -方法细节见 **`design-orchestrator-guide.md` §7.2**。不要以「世界模拟器」为默认总形态。 +方法细节见 **`design-orchestrator-guide.md` §7.2**。不要以「回合推演 / 世界模拟」为默认总形态。 ### 进入游玩 diff --git a/docs/daily-use-p0.md b/docs/daily-use-p0.md index e79ee59..81fe674 100644 --- a/docs/daily-use-p0.md +++ b/docs/daily-use-p0.md @@ -15,7 +15,7 @@ |---|------| | 1 | 日常包含 **两类**:AIRP/交互扮演 **与** 长文/爽文创作(同一运行内核) | | 2 | **UI 不自研美学**:布局/控件直接抄参考产品即可;**存档与续作稳定是硬指标** | -| 3 | **配方**:新建时用 UI **选一个**(世界模拟器 / 扩写助手…;初期可只有 1 项) | +| 3 | **进料方式 + 配方**:新建时先选进料方式(工序编排 / 对话落盘),再选对应配方(回合推演 / 快穿短局 / 扩写助手…) | | 4 | **工作流计划动态**:不在 UI 里点选死板「流程菜单」;选定配方后,由编排器听用户说话谈出本局工作流计划(创作流程)与运行规格(Worker 集)并调度 | 用户侧术语权威对照:**`ui-glossary.md` §0**(配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元)。 @@ -23,9 +23,10 @@ ### 0.2 配方 vs 工作流计划 / 运行规格(动态) ```text -配方 = 用户 UI 选一次的方法起点(可调味) +进料方式 = 新建第一层:工序编排(工作流计划)或对话落盘(对话整理产物) +配方 = 该方式下再选一次的方法起点(可调味) = 内部 recipes/ - = 例:世界模拟器、扩写助手 + = 例:回合推演、快穿短局、扩写助手 工作流计划 ≠ 再选一张固定流程卡 = 选定配方之后:用户说话 → 编排器调度创作 diff --git a/docs/play-presentation-shells.md b/docs/play-presentation-shells.md index 9abe82b..ec05ffe 100644 --- a/docs/play-presentation-shells.md +++ b/docs/play-presentation-shells.md @@ -95,6 +95,7 @@ 设计.正文组成 → 壳 + 美化(怎么展示) 输出.用户展示(主世界正文)→ 转述提取/改写后的 body;suggested_actions → 行动区 输出.用户展示(终稿)→ present.v1 或纯 Markdown(→ body) +输出.开场白 → 与终稿同形的 present.v1(shell 跟正文组成);旧稿散文仍可回退为 body ``` ## 5. 游玩灌数契约 diff --git a/docs/progressive-data-design.md b/docs/progressive-data-design.md index 1b35360..ebe6cf8 100644 --- a/docs/progressive-data-design.md +++ b/docs/progressive-data-design.md @@ -122,7 +122,7 @@ Progressive 投影 本轮主世界层只看见「当前档」;未 | **生成规则 / 具体实例** | 分档表 schema 与行数据(如好感→性格);生成后由变量设计索引 | | **正文组成** | 隐藏段定界(供解析维护语句);用户可见块序 | | **设计监控栏** | 用户可见的会变字段子集 | -| **开场白与开场变量** | 开场正文(守版式)+ 与开场同真相的初值 | +| **开场白与开场变量** | 开场正文(present.v1,跟正文组成)+ 与开场同真相的初值 / 用户角色 | | **游玩拓扑 / 上下文投影排序 / 细化终稿** | 固定槽;扁平投影序;tables 收成 | 编排口诀:体验与机制 → 要不要真值 → 映射表用生成规则/实例 → 维护语句与旁观汇总 → 开场同真相 → 收成。 diff --git a/docs/ui-glossary.md b/docs/ui-glossary.md index f4a5629..59113f5 100644 --- a/docs/ui-glossary.md +++ b/docs/ui-glossary.md @@ -4,7 +4,7 @@ **实现**:`src/server/display-labels.ts`(权威映射);`web/display-labels.js` 须与本文及该文件保持一致。新增高频 id 时:**先改本文 → 再改代码**。 -**产品口径**:用户侧与作者文档统一用**业界编排用语的中文译名**——**配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元**。旧拍摄词「导演 / 剧本 / 演员 / 能力」与「总管 / 能力包」仅作历史别称,勿再写进新文案。 +**产品口径**:用户侧与作者文档统一用**业界编排用语的中文译名**——**进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元**。旧拍摄词「导演 / 剧本 / 演员 / 能力」与「总管 / 能力包」仅作历史别称;「世界模拟器」「Boss直聘」不作进料方式名。勿再写进新文案。 文档写法:**中文为主**;英文业界叫法仅在术语表首次定义时括注,正文尽量不再夹英文。 @@ -14,7 +14,8 @@ | 中文(首选) | 业界叫法 | 用户可见含义 | 内部对应 | 旧称(勿再用) | |--------------|----------|--------------|----------|----------------| -| **配方** | Recipe | 新建时**手动选一次**的方法起点(如世界模拟器、扩写助手);给出建议的近期步骤,可按现场调味 | `recipes/` → 黑板 `创作.选用配方` | 导演(选型)、能力包、第二层「配方」选型 | +| **进料方式** | Creation intake | 新建作品**先选**的进料路径:工序编排或对话落盘;决定后面用哪一套配方目录 | `creationMode`:`recipe` / `dictate` | Worker 式、Boss直聘、转述进料(作模式名时) | +| **配方** | Recipe | 进料方式之下**再选一次**的方法起点(如回合推演、快穿短局);给出建议的近期步骤,可按现场调味 | `recipes/` → 黑板 `创作.选用配方` | 导演(选型)、能力包、第二层「配方」选型 | | **编排器** | Orchestrator / Supervisor | 会话里负责调度的 Agent:决定下一步调谁、是否追问或结束 | Main Agent / `orchestrator.md` | 导演(调度)、总管 | | **技能** | Skill / Capability module | 共用工序模块(美学纲领与交互范式…);编排器从池里选型编排 | `modules/` | 能力、组件池(作者文档可写模块) | | **工作流计划** | Workflow Plan / Horizon DAG | 本局谈成的**近期**增量有向无环图(步骤 = 技能实例);可追加、拆分、复用、修订 | `设计.创作流程` | 剧本(流程部分)、死板流程卡 | @@ -24,16 +25,21 @@ 关系口诀: ```text -用户选【配方】(世界模拟器 / 扩写助手 …) +用户先选【进料方式】(工序编排 / 对话落盘) │ ▼ -【编排器】调度 → 从【技能】里编排 → 谈成【工作流计划】→ 收成【运行规格】 +再选【配方】(回合推演、快穿短局、扩写助手 …) + │ + ▼ +【编排器】调度 → 从【技能】里编排(或对话落盘直接整理产物) + → 谈成【工作流计划】(工序编排)或落盘产物(对话落盘) + → 收成【运行规格】 │ ▼ 【执行单元】按规格上场 → 游玩 / 成稿(运行实例) ``` -**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。 +**两层、各选一次**:新建作品先选**进料方式**,再选该方式下的**配方**。不要叠「能力包 + 配方」,也不要把进料方式叫成某个配方的名字。 **调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改:在图上点该节点重进(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。 @@ -43,6 +49,33 @@ --- +## 0.1 进料方式与配方对照 + +新建作品对话框:**进料方式 → 配方 → 名称**。两套配方目录互不共享。内部协议仍用 `recipe` / `dictate`;界面、欢迎语、气泡标题用下表中文。 + +| 内部 `creationMode` | 用户可见 | 做什么 | 旧称(勿再写进新文案) | +|---------------------|----------|--------|------------------------| +| `recipe` | **工序编排** | 排出工作流计划,按节点逐步验收技能产物。适合要搭可扩展世界、长线规格 | Worker 式、工序图;曾被误称为「世界模拟器」 | +| `dictate` | **对话落盘** | 对话整理进产物;变量/映射用工具钉死;不种节点图。适合快速开玩 | Boss直聘、Boss 直聘、转述进料(作模式名时;「转述」易与叙事转述混淆) | + +作者文档可写「节点流程 / `recipes/转述/`」作目录路径,但用户主路径禁止用「Boss直聘」「世界模拟器」称呼这两种进料方式。 + +常用配方(选项名;内部 id 给机器): + +| 进料方式 | 配方(用户可见) | 内部 id | 定位 | +|----------|------------------|---------|------| +| 工序编排 | **回合推演** | `world-simulator` | 回合互动、可扩展的世界推进与角色扮演;舞台/机制/真值都可以往外长。旧称:世界模拟器(仅配方旧名,不是进料方式) | +| 工序编排 | **扩写助手** | `expand-assistant` | 大纲/分段写作、写手统筹 | +| 对话落盘 | **快穿短局** | `快穿短局` | 围着**一个**核心设定快速代入;难扩展、不适合长期经营;至多简单数值;常带一张主角名片 | +| 对话落盘 | **数据化跑团体验** | `数据化跑团体验` | AIRP / 交互式小说;设定可比短局完整,可选跨轮真值与分档映射 | +| 对话落盘 | **交互式长文生成器** | `交互式长文生成器` | 多轮钉需求与文风,连贯长文/章段(无数值跑团) | +| 对话落盘 | **文本生成器** | `文本生成器` | 创作收范例与模仿面;开玩后主世界出正文、转述出文风 | +| 对话落盘 | **正文组成** | `正文组成` | 只钉呈现格式(壳与分区) | + +**回合推演 vs 快穿短局**:前者为可经营的世界留工序;后者故意收成一核短局,不帮用户搭可扩张世界。用户要铺多区域舞台、生成规则、长期势力 → 用回合推演或数据化跑团体验,不要用快穿短局硬撑。 + +--- + ## 1. 原则 1. **内外分离**:id 给机器;中文给用户。 @@ -81,7 +114,8 @@ | Agent / Main Agent / orchestrator | **编排器** | 导演、总管(旧) | 调度执行单元、推进工作流计划;气泡可用「编排器 · 思考」。 | | Worker | **执行单元** | 演员(旧) | UI 标题用中文名;检查器可写执行单元。 | | Tool | **工具** | Tool | 编排器 tool call;气泡可用「工具 · 读黑板」。 | -| recipe(用户选型) | **配方**(选项名) | 导演、能力包(旧) | 新建下拉只出现配方选项(世界模拟器、扩写助手…)。 | +| `creationMode` | **进料方式** | Worker 式、Boss直聘(旧) | 新建第一层:工序编排 / 对话落盘。 | +| recipe(用户选型) | **配方**(选项名) | 导演、能力包(旧) | 新建第二层:该进料方式下的配方(回合推演、快穿短局、扩写助手…)。 | | Module / modules pool | **技能** | 能力、组件池(旧) | 见 `world-simulator-modules.md`。 | | `设计.创作流程` | **工作流计划** | 剧本(旧,流程部分) | 增量 DAG;非死选单。 | | `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 | @@ -101,6 +135,8 @@ | `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) | | `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 | | `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 | +| `opening-setup`(技能) | **开场白与开场变量** | 每条开场 = meta(用户角色+初值)+ 正文(present.v1,壳跟正文组成)。游玩辅助 Tab「开场」点选;没有或不采用则用当前用户角色卡 | +| `protagonist`(技能) | **主角设定** | 旧路径兜底;新开场把用户角色写在开场白 meta 里 | | `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 | | `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 | @@ -128,7 +164,7 @@ | `chance` | **机遇裁定**(主世界层 harness 批量工具;非每轮独立 LLM) | | `round-present` | **回合呈现** | -配方选项展示名示例:`world-simulator`(recipe)→ **世界模拟器**;`expand-assistant` → **扩写助手**。 +配方选项展示名示例:`world-simulator`(recipe)→ **回合推演**(旧称世界模拟器);`快穿短局` → **快穿短局**;`expand-assistant` → **扩写助手**。 **创造执行单元时**:规格里另写 `name`(中文展示名)。`ref` 仍用英文 kebab;UI 优先 `name`。 @@ -169,9 +205,12 @@ | `aesthetics-interaction` | 美学纲领与交互范式 | | `interaction` | 交互范式(旧;已并入上一行) | | `narrative_guide` | 叙事指南与故事推进 | +| `narrative-guide` | 叙事指南(对话落盘) | +| `story-progression` | 故事推进(对话落盘) | | `input_protocol` | 输入协议 | | `core_premise` | 核心前提 | | `aesthetics` | 美学纲领(旧;已并入美学纲领与交互范式) | +| `protagonist` | 主角设定 | --- @@ -200,6 +239,7 @@ - 新建作品同时出现「导演 + 配方」或「能力包 + 配方」两层选择 - 询问卡标题直接写 `design-core` - 新文案继续使用拍摄词「导演 / 剧本 / 演员 / 能力」作首选 +- 用「世界模拟器」「Boss直聘 / Boss 直聘」称呼进料方式;用「Worker 式」作用户可见进料名 技术日志、导出里的「调试附录」、开发者文档不受本条限制,但默认导出给用户的 Markdown 应走同一套映射。 diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md index dbaf875..e9edc23 100644 --- a/docs/world-simulator-modules.md +++ b/docs/world-simulator-modules.md @@ -1,7 +1,7 @@ -# 世界模拟器 · 编排器与能力撰写清单 +# 默认技能包 · 编排器与能力撰写清单 -> 给作者用。用户侧术语:**`docs/ui-glossary.md` §0**。 -> 运行:选配方 → 编排**增量**工作流计划 DAG → `design-step` 执行技能;可再扩步反复调用。 +> 包内部 id 仍为 `world-simulator`。用户可见:进料方式「工序编排 / 对话落盘」,配方「回合推演 / 快穿短局 …」——见 **`docs/ui-glossary.md` §0 / §0.1**。 +> 运行:选进料方式与配方 →(工序编排)编排**增量**工作流计划 DAG → `design-step` 执行技能;可再扩步反复调用。 > **标准范例**:`modules/aesthetics-interaction/prompt.md`。 > **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。 > **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。 @@ -94,6 +94,7 @@ boundary: … | `closer` | 可选;`true` = 创作终节点(选定后落库、关 DAG、保存定稿) | | `auto` | 可选;`true` = 程序步(提案后直接执行) | | `kind` | 可选;节点特性。缺省=普通执行步。`prior-artifact` = **先验产物**(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) | +| `intake` | 可选;`recipe` = 仅工序编排;`dictate` = 仅对话落盘。缺省=两族都可见 | | `params` | 可选;普通节点=编排期 `steps[].params` 声明(必填项须在进执行前钉齐)。〔先验产物〕=规划产物字段(可提前填,空则步内钉) | | `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | @@ -171,18 +172,21 @@ recipes/world-simulator|expand-assistant/recipe.yaml | 能力 | id | 状态 | |------|-----|------| | 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 | +| 主角设定 | `protagonist` | **已写**:有产物则挂在当前选用的用户角色卡下(不进全局列表);关掉这局就不加载 | | 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** | | 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** | | 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** | | 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` | -| 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 | +| 叙事指南与故事推进 | `narrative` | **工序编排**:一份全文双挂;结构化卡。对话落盘不排本步 | +| 叙事指南 | `narrative-guide` | **对话落盘**:遣词/笔墨/禁忌/写法档;`intake: dictate` | +| 故事推进 | `story-progression` | **对话落盘**:推进方式 + 用户输入用法;`intake: dictate` | | 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) | | 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 | | 随机范围整理 | `random-range` | **已写**:汇总检定/对抗等随机项与范围;正文组成之前;替代机遇裁定槽 | | 正文组成 | `reply-format` | **已写**:把回复呈现清单展示出来;选壳+美化,可写 CSS/HTML/JS;可接隐藏备用随机区 | | 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 | | 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 | -| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值;**〔收口〕**选定后落库并保存定稿 | +| 开场白与开场变量 | `opening-setup` | **已写**:每条 = meta(初值/用户角色)+ present.v1 正文(壳跟正文组成);**〔收口〕**选定后落库并保存定稿 | 共用收成(池内保留,按需): diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md index c9b9057..017b028 100644 --- a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -17,11 +17,12 @@ when: | 无上游,通常作第一步。 when_not: | 这三件已有等价定稿、用户未要求重谈 → 不重复排入。 - 只改文风 → 「叙事指南与故事推进」;只改每轮看什么 → 「回复呈现」;只改排版/壳 → 「正文组成」。 + 只改文风 → 「叙事指南」(工序编排:「叙事指南与故事推进」);只改推进或输入用法 → 「故事推进」(工序编排同上);只改每轮看什么 → 「回复呈现」;只改排版/壳 → 「正文组成」。 只改舞台、规则、真值、槽位 → 对应下游技能。与「给玩家什么体验」无关 → 不排。 boundary: | 本技能:体验是什么、怎么发生在用户身上、轮转怎么停。产出供后续编排阅读的开头节点。 - 叙事指南与故事推进:把感觉落成遣词/笔墨/禁忌与推进——本步只钉感觉与轮转。 + 叙事指南:把感觉落成遣词/笔墨/禁忌(对话落盘)——本步只钉感觉与轮转。工序编排二者并入「叙事指南与故事推进」。 + 故事推进:怎么往前、输入用法(对话落盘)——本步只钉感觉与轮转。 舞台骨架 / 具体实例 / 生成规则:舞台与可执行规则——本步不定。 实现机制 / 变量* / 回复呈现 / 正文组成 / 游玩拓扑 / 上下文投影排序 / 细化终稿:落到可运行结构——本步不定。 feeds: design_only diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml index fdac8cd..bbf3fa5 100644 --- a/skills/dialogue/world-simulator/modules/catalog.yaml +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -9,6 +9,7 @@ # closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿 # auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收) # kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行) +# intake = 可选;recipe=仅工序编排 DAG;dictate=仅对话落盘何时落盘。缺省=两族都可见 # params = 可选;普通节点=编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐) # 〔先验产物〕=规划产物字段(可提前填,空则步内钉) # opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md) @@ -35,6 +36,13 @@ modules: 询问相近故合为一步。 artifact: 设计.美学纲领与交互范式 + - id: protagonist + name: 主角设定 + declaration: >- + 钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等)。 + 仅当开场白 meta 不够用时才排;用户角色优先写在开场白里。 + artifact: 设计.主角设定 + # —— 世界与内容 —— - id: world-blueprint name: 舞台骨架 @@ -82,11 +90,28 @@ modules: - id: narrative name: 叙事指南与故事推进 + intake: recipe declaration: >- 美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式; - 不重做体验问卷 + 不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进」 artifact: 设计.叙事指南与故事推进 + - id: narrative-guide + name: 叙事指南 + intake: dictate + declaration: >- + 美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与写法档; + 不重做体验问卷,也不钉故事怎么往前 + artifact: 设计.叙事指南 + + - id: story-progression + name: 故事推进 + intake: dictate + declaration: >- + 美学已有方向时:钉故事怎么往前、用户输入用法(大纲扩写或扮演停笔); + 不重做文风问卷 + artifact: 设计.故事推进 + # —— 机制与数据 —— - id: mechanism name: 实现机制 @@ -143,7 +168,7 @@ modules: name: 开场白与开场变量 closer: true declaration: >- - 创作终节点:写出 1~多条开场白(正文须含字面 @玩家)并钉同真相初值; + 创作终节点:写出 1~多条开场(每条 = meta 用户角色 + 正文;须含字面 @玩家)并钉同真相初值; 选定后程序落库、结束创作并保存定稿 artifact: 设计.开场白与开场变量 diff --git a/skills/dialogue/world-simulator/modules/context-order/prompt.md b/skills/dialogue/world-simulator/modules/context-order/prompt.md index 6c0ff2e..3f2dabe 100644 --- a/skills/dialogue/world-simulator/modules/context-order/prompt.md +++ b/skills/dialogue/world-simulator/modules/context-order/prompt.md @@ -56,7 +56,7 @@ feeds: design_only 2. 禁止重写上游长文;只写顺序与投影。 3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。 4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。 -5. 主世界层应双挂「设计.叙事指南与故事推进」等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。 +5. 主世界层应挂「设计.叙事指南」「设计.故事推进」(工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。 6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。 7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。 8. 同一内容可挂多槽。 diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md index 4782652..6be24d5 100644 --- a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -21,11 +21,12 @@ when_not: | 整体感觉未钉 → 先「美学纲领与交互范式」。 常识能推、也不需要精准格式(现实名车、西幻常见种族)→ 不排。 只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。 - 跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「叙事指南与故事推进」。 + 跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「故事推进」(工序编排:「叙事指南与故事推进」)。 boundary: | 本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。 具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。 - 舞台骨架 / 实现机制:可引用,不重写百科、不重钉既成法则。 + 舞台骨架 / 实现机制:上游真值只引用——`依据`/`硬约束`写短引用句或最短填写条款;社会格局、世界状况、既成法则正文不在本步重写。 + 缺上游真值时:本步仍只写填写合同时必须守的最短条款;格局/法则长文交给舞台骨架或实现机制,本步不代写百科、不发明「共通前提」迷你世界书。 变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。 随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。 旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。 @@ -43,7 +44,7 @@ feeds: gm,auditor 1. 生成出来必须钉死哪些字段吗?(如武功典籍的名字、效果、阶层)没有就说没有。 2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西? 3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成? -4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。 +4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批,关系用一两句钉互指即可;彼此独立 → 一次一条。台上格局、世界律、前史不在这里展开。 5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。 想到什么写什么;完整规则在正式产物里钉。 @@ -107,7 +108,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生 8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。 9. 生命周期先行;未定前不假装可执行「具体实例」。 10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。 -11. 不写变量更新公式、不写剧情推进、不填最终实例。 +11. **上游真值引用,不重述**:舞台格局、世界状况、既成法则已有 → `依据`/`硬约束`点名引用;没有 → 只写最短填写条款,并在追问或开放问题标「应落舞台骨架/实现机制」。禁止用「共通前提」类长文重写世界书。 +12. **同批关系只钉互指**:`一次生成.关系`与字段间约束写清谁对谁(对立/亲属/席位互指等);不借关系段落写前史、社会律、权力百科。 +13. 不写变量更新公式、不写剧情推进、不填最终实例。 ``` ## probe @@ -115,9 +118,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生 ```probe 每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。 -优先:整条规则是否必要;实例彼此有无必须同场对齐的关系;某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。 +优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(只追互指,不追世界书);某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。 若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。 -不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、再给哪个字段加一套枚举池。 +不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、台上格局/既成法则长文(→ 舞台骨架/实现机制)、再给哪个字段加一套枚举池。 ``` ## output @@ -345,4 +348,5 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生 - 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。 - 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。 - 把写好的 NPC 整份塞进池当最终实例。 +- 把社会格局、前史、既成律写成「共通前提」长文塞进规则;应引用舞台/机制,或只留最短填写硬约束。 ``` diff --git a/skills/dialogue/world-simulator/modules/mechanism/prompt.md b/skills/dialogue/world-simulator/modules/mechanism/prompt.md index 68b8d58..18869d5 100644 --- a/skills/dialogue/world-simulator/modules/mechanism/prompt.md +++ b/skills/dialogue/world-simulator/modules/mechanism/prompt.md @@ -21,8 +21,10 @@ when_not: | boundary: | 本技能:点名故事当作既成事实的变造法则,写清如何约束后续;不补起源、不写舞台百科、不写人物名片。 美学纲领与交互范式:体验与轮转——本步不重定。 - 舞台骨架:环境与社会格局。具体实例 / 开场白与开场变量:名片与第一屏(身份特殊走它们)。 - 生成规则 / 变量* / 叙事指南与故事推进:持续生成、跨轮真值、遣词推进——本步只钉前提法则。 + 舞台骨架:环境与社会格局(谁在台上、状况如何转)——本步只钉缺了会换骨架的既成律。 + 具体实例 / 开场白与开场变量:名片与第一屏(身份特殊走它们)。 + 生成规则:可引用本步法则作填写硬约束;本步不写对象族 schema/池,也不把法则扩成名片填写圣经。 + 变量* / 叙事指南 / 故事推进:跨轮真值、遣词、推进(工序编排后二者合为「叙事指南与故事推进」)——本步只钉前提法则。 feeds: gm ``` diff --git a/skills/dialogue/world-simulator/modules/narrative-guide/prompt.md b/skills/dialogue/world-simulator/modules/narrative-guide/prompt.md new file mode 100644 index 0000000..b067283 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/narrative-guide/prompt.md @@ -0,0 +1,160 @@ +# 叙事指南 + +> 对话落盘技能。程序只切割下方 **fence 块**;`##` 标题仅供人读。 +> 产物外壳:`docs/context-fragment-design.md`。 +> 工序编排仍用「叙事指南与故事推进」;本步只钉怎么写,不钉故事怎么往前。 + +## meta + +```meta +name: 叙事指南 +id: narrative-guide +artifact: 设计.叙事指南 +declaration: > + 美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与写法档;不重做体验问卷,也不钉故事怎么往前 +when: | + 已有美学纲领(或等价),但游玩正文仍不知道怎么写:遣词什么味道、笔墨落谁身上、场合换哪一档写法、绝对不能写什么。 + 用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。 + 交互范式或故事推进已钉停笔/轮转,不构成不排本步的理由。 +when_not: | + 本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。 + 美学未钉 → 先「美学纲领与交互范式」。 + 用户只要感觉/站位、不要文风细则 → 不排,沿用美学。 + 只谈故事怎么往前、用户输入用法 → 「故事推进」。 + 版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。 +boundary: | + 本技能:一份 context-fragment,写清怎么写(遣词、笔墨、禁忌、写法档)。 + 故事推进:怎么往前、输入用法——本步不定。 + 美学:感觉与轮转。生成规则/变量*:合同与表。 +feeds: narrator,gm +``` + +## opening + +```opening +美学里的感觉已有方向时,请谈「正文怎么写」(想到什么写什么): + +1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写? +2. 正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。 +``` + +## task + +```task +你正在执行「叙事指南」。产物必须是 **context-fragment.v1** JSON,写入「设计.叙事指南」。 + +把美学里的感觉落成可执行的写法。作用对象是本局游玩正文(转述与主世界层都读这份)。 + +执行顺序: +1. 继承美学体验与禁忌,改写成做法向句子。 +2. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好。 + 遣词用户未提段落形态时,默认写入:完整长自然段叙述,不要单句成段(篇幅数字交给「回复呈现」)。 +3. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。 +4. 自评 + 追问。故事怎么往前、用户输入用法交给「故事推进」,本步不写。 + +summary:`叙事指南 · …` +``` + +## principles + +```principles +1. 感觉 vs 做法:美学写要什么爽;本步写怎么遣词、笔墨落哪。 +2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。 + 用户未提及时:完整长自然段,不要单句成段。 +3. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 +4. 字数区间归「回复呈现」;推进与输入用法归「故事推进」。 +``` + +## probe + +```probe +本步只钉三件:遣词可执行、笔墨落点、绝对禁忌(含写法档有无切换)。 + +已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。 +用户未提段落形态时:写入长自然段默认并简短确认即可。 + +缺遣词/笔墨/禁忌其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。三件够验收:`追问.题目` 为 `[]`。 + +用户谈到推进、停笔、大纲扩写:写入「开放问题」,交给「故事推进」。 +用户谈到变量增减、生成规则、舞台百科:只抽取能改遣词 / 禁忌的一句,其余写入「开放问题」。 +用户提到要换写法:收成写法档写入「情境备用」(官能 / 爽文 / 恐怖 / 情色这类),不追问具体场合怎么摆;没提则 `[]`。 +字数问「回复呈现」;本步不追问字数。 +``` + +## output + +```output +{ + "schema": "context-fragment.v1", + "技能": "叙事指南", + "brief": "一句话:体裁 + 笔墨重心", + "mount": ["narrator", "world-simulator"], + "稳变": "stable", + "正文": { + "依据的体验": ["…"], + "叙事纲领": { + "核心定位": "…", + "叙事使命": "…", + "叙事追求": ["…"] + }, + "风格与遣词": { + "叙述者人格": "…", + "整体基调": "…", + "遣词造句": ["原则 - 做法 → 目的"], + "示范": [{ "情境": "常态", "段落": "…" }] + }, + "笔墨焦点": { + "观众与代入取向": "…", + "详写优先": ["…"], + "可略写": ["…"] + }, + "禁忌与不偏好": { + "绝对禁忌": ["…"], + "不偏好": ["…"] + }, + "情境备用": [ + { + "名字": "展示情色", + "用法": "切到身体与权力交换时:感官词加重、节奏放慢、对话变短;切回日常则收。" + }, + { + "名字": "网文爽文", + "用法": "打脸/反转时:可复述对方原话、结果写满、过程可略。" + } + ], + "内容与表达": { + "视角执行": "…", + "段落形态": "默认:完整长自然段叙述,不要单句成段;用户另定则改写", + "场景详略映射": ["…"] + } + }, + "自评": { + "维度": [ + { "名": "写法可执行", "分数": 8, "说明": "…" }, + { "名": "与美学对齐", "分数": 8, "说明": "…" } + ], + "薄弱点": "" + }, + "追问": { "导语": "", "题目": [] }, + "开放问题": [] +} +``` + +硬规则:合法 JSON;示范至少 1 段;禁忌与不偏好都要有;禁止 order;summary 见 task。 +情境备用若有:名字必须是写法档(官能小说 / 网文爽文 / 展示恐怖 / 展示情色 同级),用法写该档的遣词与切入口;不是「当众折辱」「物资告急」这类具体桥段。无切换则 `[]`。 +不写「推进与决策」。 + +## checklist + +```checklist +- [ ] 遣词+示范+禁忌够用? +- [ ] 情境备用若有,是否是写法档而非具体桥段?无切换是否为 `[]`? +- [ ] 未把推进/输入用法写进本步? +``` + +## examples + +```examples +好:一次钉清遣词 + 笔墨落点 + 禁忌;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。 +坏:追问好感公式 / 事件池 / 大纲扩写还是扮演停笔;或把「当众折辱」「独处一室」当成情境备用。 +``` diff --git a/skills/dialogue/world-simulator/modules/narrative/prompt.md b/skills/dialogue/world-simulator/modules/narrative/prompt.md index f8632fa..bada053 100644 --- a/skills/dialogue/world-simulator/modules/narrative/prompt.md +++ b/skills/dialogue/world-simulator/modules/narrative/prompt.md @@ -3,6 +3,7 @@ > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。 > **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。 +> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进」,不要在落盘池排本步。 > 旧称:叙事指南。 ## meta @@ -17,6 +18,7 @@ when: | 已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。 用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。 when_not: | + 本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进」)。 美学未钉 → 先「美学纲领与交互范式」。 用户只要感觉/站位、不要文风细则 → 不排,沿用美学。 版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。 @@ -73,7 +75,7 @@ summary:`叙事指南与故事推进 · …` - 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。 - 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。 4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 -5. 整份双挂即可;禁止拆成两个技能。 +5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。 6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。 ``` diff --git a/skills/dialogue/world-simulator/modules/opening-setup/prompt.md b/skills/dialogue/world-simulator/modules/opening-setup/prompt.md index 7bfccac..6e46870 100644 --- a/skills/dialogue/world-simulator/modules/opening-setup/prompt.md +++ b/skills/dialogue/world-simulator/modules/opening-setup/prompt.md @@ -2,7 +2,7 @@ > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 产物外壳:`docs/context-fragment-design.md`。 -> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场白供选定;选定后程序落库、关闭工作流、保存定稿。 +> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场(每条 = meta 初值/角色 + present.v1 正文)供选定;选定后程序落库、关闭工作流、保存定稿。 > 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。 ## meta @@ -12,20 +12,21 @@ name: 开场白与开场变量 id: opening-setup artifact: 设计.开场白与开场变量 declaration: > - 创作终节点:写出 1~多条开场白(须含字面 @玩家)并钉同真相初值;选定后落库、结束创作并保存定稿 + 创作终节点:写出 1~多条开场(每条 = meta 初值/用户角色 + 正文 present.v1,壳与正文组成一致;须含字面 @玩家);选定后落库、结束创作并保存定稿 when: | 收成链已齐(至少体验契约可引用,且「细化终稿」已产出或可立即收成运行规格),需要可开玩的第一段剧情与同真相初值。 排在收成之后作最后一步。用户要进游玩但还没有第一屏 → 排。 when_not: | 体验/呈现仍混沌 → 先「美学纲领与交互范式」与「回复呈现」「正文组成」等。 - 只要改文风不要开场 → 「叙事指南与故事推进」。 + 只要改文风不要开场 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。 只要改真值规则不要开场文 → 「变量设计与更新规则」。 运行规格未齐 → 先「细化终稿」。尚未选定开场前若要补前序节点(如 NPC)→ 插在本步之前,勿以「收口已排入」拒绝追加。 boundary: | - 本技能是创作终节点:1~多条开场正文(按回复呈现清单 + 正文组成展示)+ 开场变量初值。 - 必须遵守已验收的叙事指南、回复呈现、正文组成、变量设计;不重做这些契约。 + 本技能是创作终节点:1~多条开场。每条 = meta(用户角色名字+简介、开场变量初值)+ 正文(present.v1,shell 与「设计.正文组成」一致)。 + 用户角色跟这条开场走;不同开场可有不同用户角色。初值与该条叙述同真相。 + 必须遵守已验收的叙事指南、回复呈现、正文组成、变量设计;不重做这些契约。正文不得只交一段散文。 具体实例:开场可引用已有实例名片,不在本步新造百科。 - 选定后由程序写入 输出.开场白 / 运行.初始变量 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。 + 选定后由程序写入 输出.开场白 / 运行.初始变量 / 运行.开场用户角色 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。 feeds: narrator ``` @@ -44,21 +45,31 @@ feeds: narrator ```task 你正在执行「开场白与开场变量」。这是创作最后一步。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。 -主产物:1~3 条可开玩开场白(选定后落库结束创作)。开场须服从叙事指南、正文组成可见块序、监控栏(若有)、变量真值。 +主产物:1~3 条可开玩开场(选定后落库结束创作)。每条必须是 **meta + 正文** 两截,不是一段散文。 + +**正文 = 正文组成的输出格式(强制):** +- 读「设计.正文组成」的 `呈现壳.shell_id` 与可见区域。 +- 每条 `正文` 必须是 **present.v1 对象**(与游玩终稿、正文组成「示例.灌数」同形)。`shell` 必须等于该 shell_id。只填该壳已有区域;没有的键省略或空字符串。 +- 用户主读写在 `blocks.body`;监控栏初值写在 `blocks.monitor`(短键值,勿长文);建议行动写在 `正文.meta.suggested_actions`。 +- prose 壳也要包成 present.v1(`blocks.body`),不要只交裸 Markdown。 +- 开场比一章短:愿点进游玩即可,不必凑游玩默认 1000~2000 字。 + +**meta = 这条开场的初始设定:** +- `用户角色`:名字+简介(有代入则必填;不同开场可以不同)。 +- `开场变量`:与该条叙述同真相的初值(名与变量设计一致)。顶层再抄一份推荐条的初值,供程序落库。 **用户角色占位(产物规格,不是可选项):** -- `开场白全文`、`开场白候选`、有字的 `开场可见块.内容` 里,凡指称用户扮演角色,第三人称点名处必须写字面量 `@玩家`(六个字符:`@` + `玩家`)。 -- 每条候选全文至少出现一次 `@玩家`。禁止写死具体姓名(如林晚、阿泽);禁止用「用户」「主角」顶名字位。 +- `开场白全文`、每条候选 `正文`(含 `blocks.body` 及其它有字区域)里,凡指称用户扮演角色,第三人称点名处必须写字面量 `@玩家`(六个字符:`@` + `玩家`)。 +- 每条候选正文至少出现一次 `@玩家`。禁止写死具体姓名(如林晚、阿泽);禁止用「用户」「主角」顶名字位。 - 「你」可与 `@玩家` 并用;若全文只有「你」、零个 `@玩家`,视为不合格,改写后再交。 - 进游玩时程序把 `@玩家` 换成当前用户角色名;创作稿必须保留字面 `@玩家`。 执行顺序: -1. 读依赖:worker 集/美学/叙事/正文组成/监控栏/变量设计/舞台与实例。 -2. 写 1~3 条开场候选(同真相、第一拍可不同)→ `开场白全文` + `开场白候选`(含 `@玩家`)。 -3. 按正文组成可见块填 `开场可见块`;隐藏段通常空。 -4. 钉开场变量(与开场同真相;冲突以叙述为准)。 -5. 若有监控栏:开场快照(短)。 -6. 交稿前自检:全文搜索确认每条候选含 `@玩家`;再自评 + 追问;输出 JSON。 +1. 读依赖:worker 集/美学/叙事/**正文组成(先抄 shell_id)**/监控栏/变量设计/舞台与实例。 +2. 写 1~3 条候选:每条 `meta`(用户角色+开场变量)+ `正文`(present.v1)。同真相、第一拍可不同。 +3. 推荐条的 present.v1 同时写入 `开场白全文`(对象,不是字符串)。 +4. 顶层 `开场变量` = 推荐条 meta 里那份初值。冲突以叙述为准。 +5. 交稿前自检:每条 `正文.shell` 与正文组成一致;JSON 搜索 `@玩家`;再自评 + 追问;输出 JSON。 缺上游契约时:能写则按已有材料写开场并标开放问题;不能成立第一拍则追问 1~2 点,勿交空壳。 @@ -69,9 +80,9 @@ summary:`开场白与开场变量 · …`(点题场景)。 ```principles 1. 开场为主,表为配套。 -2. 版式跟正文组成;文风跟叙事指南。 -3. 同真相:开场与开场变量一致。 -4. 可行动:第一拍结束有下一步空间。 +2. 正文 = present.v1,壳与「设计.正文组成」一致;文风跟叙事指南。尚未钉壳时用 prose 包一层 present.v1,并标开放问题。 +3. 同真相:该条 meta.开场变量 与该条叙述一致。 +4. 可行动:第一拍结束有下一步空间;写入 suggested_actions(该壳开行动时)。 5. 短:愿点进游玩,非第一章。 6. 不剧透隐藏档。 7. 用户角色名位 = 字面 `@玩家`(见 task 规格);不合格则改写。 @@ -80,7 +91,7 @@ summary:`开场白与开场变量 · …`(点题场景)。 ## probe ```probe -开场地点/冲突不清;某真值开场值推不出;版式块是否都要在开场出现;候选是否漏写 `@玩家`。一次 1~2 点。 +开场地点/冲突不清;某真值开场值推不出;壳区域是否都要填;候选是否漏写 `@玩家` 或写成了裸散文。一次 1~2 点。 ``` ## output @@ -94,19 +105,39 @@ summary:`开场白与开场变量 · …`(点题场景)。 "稳变": "stable", "正文": { "依据契约": [ - "正文组成 / 叙事指南 / 监控栏 / 变量设计 等已遵循要点" + "正文组成 / 叙事指南 / 监控栏 / 变量设计 等已遵循要点;写出所用 shell_id" ], - "开场可见块": [ - { - "块id": "与正文组成.visible块一致", - "内容": "该块开场文本;点名用户角色写 @玩家" - } - ], - "开场白全文": "例:清晨,@玩家推开宿舍门,走廊里只有冷风。……", + "用户角色": { + "名字": "沈聿", + "简介": "本地放贷人;与这对母女相识但不熟。" + }, + "开场白全文": { + "schema": "present.v1", + "shell": "与设计.正文组成.呈现壳.shell_id 一致", + "blocks": { + "monitor": { "地点": "堂屋", "债额": "三两" }, + "body": "清晨,@玩家推开堂屋门,债契还摊在桌上。……" + }, + "meta": { "suggested_actions": ["问债", "先坐下"] } + }, "开场白候选": [ - "推荐那条(与开场白全文相同;必含 @玩家)", - "可选第二条(必含 @玩家)", - "可选第三条(必含 @玩家)" + { + "meta": { + "用户角色": { "名字": "沈聿", "简介": "本地放贷人;与这对母女相识但不熟。" }, + "开场变量": [ + { "名": "债额", "值": 3, "依据": "桌上债契" } + ] + }, + "正文": { + "schema": "present.v1", + "shell": "与开场白全文.shell 相同", + "blocks": { + "monitor": { "地点": "堂屋", "债额": "三两" }, + "body": "清晨,@玩家推开堂屋门,债契还摊在桌上。……" + }, + "meta": { "suggested_actions": ["问债", "先坐下"] } + } + } ], "开场变量": [ { @@ -115,16 +146,11 @@ summary:`开场白与开场变量 · …`(点题场景)。 "依据": "开场事实/设定推断" } ], - "监控栏开场快照": [ - { - "名": "监控栏字段名", - "显示": "短展示" - } - ], - "隐藏段开场": "通常空;若有定界内容则写出", + "隐藏段开场": "通常空;若正文组成钉了隐藏段且开场必须写出,写入 present.v1 blocks.hidden", "落库提示": { - "输出.开场白": "开场白全文", - "运行.初始变量": "开场变量 → 表格式", + "输出.开场白": "推荐条 正文(present.v1 JSON)", + "运行.初始变量": "该条 meta.开场变量 → 表格式", + "运行.开场用户角色": "该条 meta.用户角色 → 名字+简介", "变量.当前": "与初始变量对齐" }, "用户可行动空间": "开场结束后玩家显然能尝试什么" @@ -134,7 +160,7 @@ summary:`开场白与开场变量 · …`(点题场景)。 { "名": "契约符合", "分数": 8, - "说明": "是否遵循正文组成/叙事/监控栏" + "说明": "是否 present.v1、shell 与正文组成一致、叙事/变量同真相" }, { "名": "同真相", @@ -150,6 +176,11 @@ summary:`开场白与开场变量 · …`(点题场景)。 "名": "玩家占位", "分数": 8, "说明": "每条候选是否含字面 @玩家、无写死姓名" + }, + { + "名": "版式符合", + "分数": 8, + "说明": "是否 present.v1,shell 与正文组成一致,区域未超出该壳" } ], "薄弱点": "一句话" @@ -169,25 +200,30 @@ summary:`开场白与开场变量 · …`(点题场景)。 ``` 硬规则: -1. context-fragment.v1;自评含「玩家占位」。 -2. `开场白全文` 与 `开场变量` 必填;`开场白候选` 1~3 条;监控栏无则为 `[]`。 -3. 每条开场候选正文必须含字面 `@玩家`;不发明新真值名。 -4. summary:`开场白与开场变量 · {brief 缩略}`。 +1. context-fragment.v1;自评含「玩家占位」与「版式符合」。 +2. `开场白全文` 与每条候选 `正文` 必须是 present.v1 对象(含 `schema`/`shell`/`blocks.body`);`shell` 与「设计.正文组成」一致。禁止只交散文字符串。 +3. `开场白候选` 1~3 条,每条含 `meta`(用户角色有代入则必填;开场变量与该条同真相)+ `正文`。顶层 `开场变量` 必填(推荐条初值)。 +4. 每条正文(含 body)必须含字面 `@玩家`;不发明新真值名。 +5. summary:`开场白与开场变量 · {brief 缩略}`。 ## checklist ```checklist -- [ ] 1~3 条候选,推荐条 = `开场白全文`? -- [ ] 每条候选全文可搜到 `@玩家`?无写死姓名? -- [ ] 版式与叙事口径?变量同真相?有行动空间? +- [ ] 1~3 条候选,推荐条 正文 = `开场白全文`(均为 present.v1)? +- [ ] `shell` 与「设计.正文组成」一致?只填该壳已有区域?不是裸散文? +- [ ] 每条候选可搜到 `@玩家`?无写死姓名? +- [ ] 该条 meta 初值与叙述同真相?有行动空间? - [ ] 未剧透隐藏档、未写成第一章? ``` ## examples ```examples -好:「冷风灌进来时,@玩家还握着空水瓶。」(有字面 @玩家) -好:「你(@玩家)站在闸机前,票夹是空的。」(你 + @玩家) +好:正文为 present.v1,shell 与正文组成相同;body 里有字面 @玩家;meta 带用户角色与开场变量。 +好:prose 壳也包成 {"schema":"present.v1","shell":"prose","blocks":{"body":"冷风灌进来时,@玩家还握着空水瓶。"}}。 +好:「你(@玩家)站在闸机前,票夹是空的。」写在 blocks.body。 +坏:`开场白全文` 只是一段字符串,没有 present.v1。 +坏:shell 与正文组成不一致,或灌了该壳没有的区域。 坏:全文只有「你推开门」——零个 `@玩家`。 坏:「林晚推开门」——写死姓名。 ``` diff --git a/skills/dialogue/world-simulator/modules/protagonist/prompt.md b/skills/dialogue/world-simulator/modules/protagonist/prompt.md new file mode 100644 index 0000000..5161fe4 --- /dev/null +++ b/skills/dialogue/world-simulator/modules/protagonist/prompt.md @@ -0,0 +1,122 @@ +# 主角设定 + +> 技能文档。程序只切割下方 **fence 块**。 +> 产物外壳:`docs/context-fragment-design.md`。 +> 落盘后挂在当前选用的用户角色卡下(横线下方),不进全局列表。关掉这局就不加载。不改其它技能正文。 + +## meta + +```meta +name: 主角设定 +id: protagonist +artifact: 设计.主角设定 +declaration: > + 钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等)。落盘后挂在当前选用的用户角色卡下。 +when: | + 用户代入,且这局角色有名字、身份或金手指(快穿短局几乎总有一张脸),尚无「设计.主角设定」定稿。 +when_not: | + 旁观或把角色当棋子、不要代入身份 → 不排。 + 只要体验感觉、不要名片 → 「美学纲领与交互范式」。 + NPC 名片 → 「具体实例」。开场第一拍 → 「开场白与开场变量」。 +boundary: | + 本技能:@玩家 扮演的那一张名片(名字、背景、特殊设定)。 + 美学纲领与交互范式:体验与轮转——本步不重定。 + 实现机制:世界法则——金手指写在本步「它能做什么」,法则来源不写。 + 开场白:第一屏怎么开始——本步不定时间地点冲突。 +feeds: gm +``` + +## opening + +```opening +这局你代入谁?(想到什么写什么) + +1. 名字:别人怎么叫你?不要写 @玩家。 +2. 背景:一两句来历或处境即可,不写世界百科。 +3. 特殊设定:身份、金手指、随身之物;没有就说没有。 +``` + +## task + +```task +把用户原话收成 @玩家 扮演的那一张名片,写入 tag「设计.主角设定」。作用对象是本局黑板;程序读取后挂在当前选用的用户角色卡下,不写进全局列表。 + +产物必须是 **context-fragment.v1** JSON。正文只含:名字、背景、特殊设定(身份 / 金手指 / 其它)。须含 schema / 技能 / brief;mount 主世界层;稳变多为 stable。 + +名字:本局称呼,进游玩替换 @玩家。禁止写成「@玩家」「用户」「主角」。缺名字 → 追问 1 题,不交半张卡。 +背景:一两句。没有就写「未定」,不编百科。 +特殊设定:有则写身份、金手指(能做什么、限制一句)、其它;没有就各写「无」。 + +一次对话、一次验收。不写世界、不写文风、不写开场第一拍。 +``` + +## principles + +```principles +1. 一张脸:删掉仍知道「我是谁」的句子才落盘。 +2. 金手指写能力与限制,不写它为何存在。 +3. 材料不够:名字必须问出;背景和特殊设定可「未定」或「无」。 +4. 规格走 JSON;追问一次 1 题。 +``` + +## probe + +```probe +只追问挡住成卡的缺口。缺名字:给 2 个称呼选项 + 其它。背景/金手指用户说没有 → 写「无」,不问第三轮。 +``` + +## output + +```output +{ + "schema": "context-fragment.v1", + "技能": "主角设定", + "brief": "一句话:谁、带着什么特殊", + "mount": ["world-simulator"], + "稳变": "stable", + "正文": { + "名字": "林晚", + "背景": "原剧情里被退婚的庶女,刚穿过来那天正被当众羞辱。", + "特殊设定": { + "身份": "侯府庶女 / 原女主的垫脚石", + "金手指": "能看见别人对她的好感数字;别人看不见。", + "其它": "无" + } + }, + "自评": { + "维度": [ + { "名": "可代入", "分数": 8, "说明": "名字能替换 @玩家,身份清楚" }, + { "名": "够用", "分数": 8, "说明": "没有世界百科,特殊设定能约束正文" } + ], + "薄弱点": "一句话" + }, + "追问": { + "导语": "名片还缺:", + "题目": [] + }, + "开放问题": [] +} +``` + +硬规则: +1. 合法 JSON;必含 schema / 技能 / brief / 正文 / 自评 / 追问。 +2. 正文是对象,键为 名字 / 背景 / 特殊设定;特殊设定为对象(身份、金手指、其它),不是一段散文。 +3. 名字不是 @玩家 / 用户 / 主角。 +4. 禁止写数字 order、禁止输出执行单元列表。 +5. summary:`主角设定 · {名字}`。 + +## checklist + +```checklist +- [ ] 含 schema / brief / 正文 / 自评 / 追问? +- [ ] 名字可替换 @玩家?不是占位词? +- [ ] 背景一两句或「未定」?特殊设定有则写能力与限制,无则「无」? +- [ ] 没有世界百科、没有开场第一拍? +``` + +## examples + +```examples +好:名字「林晚」;背景一句穿来当日处境;金手指「看见好感数字」+限制「别人看不见」。 +坏:名字写成 @玩家;把侯府地理、朝堂势力写进背景;把开场羞辱戏全文写进名片。 +``` diff --git a/skills/dialogue/world-simulator/modules/refine/prompt.md b/skills/dialogue/world-simulator/modules/refine/prompt.md index 94a69ad..820a731 100644 --- a/skills/dialogue/world-simulator/modules/refine/prompt.md +++ b/skills/dialogue/world-simulator/modules/refine/prompt.md @@ -50,7 +50,7 @@ boundary: | - 设计.美学纲领与交互范式 → interaction / experience_check - 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots - 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化 -- 设计.叙事指南与故事推进(旧称 设计.叙事指南)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序) +- 设计.叙事指南 / 设计.故事推进(工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序) - 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要) - 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句 diff --git a/skills/dialogue/world-simulator/modules/reply-format/prompt.md b/skills/dialogue/world-simulator/modules/reply-format/prompt.md index f86e9a2..706ca9b 100644 --- a/skills/dialogue/world-simulator/modules/reply-format/prompt.md +++ b/skills/dialogue/world-simulator/modules/reply-format/prompt.md @@ -21,7 +21,7 @@ when_not: | 还不知道每轮给用户看什么 → 先「回复呈现」。 已定要检定/掷骰但范围表未收 → 先「随机范围整理」。 不要排在开场白之后。几乎不跳过。 - 只谈文风遣词 → 「叙事指南与故事推进」。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。 + 只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。 boundary: | 本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式、美化排版(可含 CSS/HTML/JS);若有随机范围表,预留/接到隐藏备用随机区(用户看不见、发给 AI 仍在上下文)。 回复呈现:必选/可选区域 + 各区简单内容规则——本步不重议清单与规则,只负责怎么显示。 diff --git a/skills/dialogue/world-simulator/modules/status-bar/prompt.md b/skills/dialogue/world-simulator/modules/status-bar/prompt.md index 06b64e4..e08980b 100644 --- a/skills/dialogue/world-simulator/modules/status-bar/prompt.md +++ b/skills/dialogue/world-simulator/modules/status-bar/prompt.md @@ -19,7 +19,7 @@ when: | when_not: | 上游还没齐 → 先那些。 只谈怎么排版、选壳、CSS/HTML/JS → 「正文组成」。 - 只谈文风遣词 → 「叙事指南与故事推进」。 + 只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。 检定/掷骰范围汇总 → 「随机范围整理」。 变量如何增减 → 「变量设计与更新规则」(本步只定展示哪些、展示规则)。 boundary: > diff --git a/skills/dialogue/world-simulator/modules/story-progression/prompt.md b/skills/dialogue/world-simulator/modules/story-progression/prompt.md new file mode 100644 index 0000000..6f45a1f --- /dev/null +++ b/skills/dialogue/world-simulator/modules/story-progression/prompt.md @@ -0,0 +1,134 @@ +# 故事推进 + +> 对话落盘技能。程序只切割下方 **fence 块**;`##` 标题仅供人读。 +> 产物外壳:`docs/context-fragment-design.md`。 +> 工序编排仍用「叙事指南与故事推进」;本步只钉怎么往前与用户输入用法,不钉遣词。 + +## meta + +```meta +name: 故事推进 +id: story-progression +artifact: 设计.故事推进 +declaration: > + 美学已有方向时:钉故事怎么往前、用户输入用法(大纲扩写或扮演停笔);不重做文风问卷 +when: | + 已有美学纲领(或等价),但尚未钉用户输入用法(大纲扩写 / 扮演停笔),或还不知道故事靠场面自己推、等用户、还是无专门推进。 + 用户主动谈推进、节奏、停笔、大纲扩写也排。 + 遣词未钉、叙事指南未落,不构成不排本步的理由。 +when_not: | + 本技能只给对话落盘。工序编排不排本步(编排池是「叙事指南与故事推进」)。 + 美学未钉 → 先「美学纲领与交互范式」。 + 只改遣词、笔墨、写法档 → 「叙事指南」。 + 事件池 schema、真值增减 → 「生成规则」/「变量设计与更新规则」。 +boundary: | + 本技能:一份 context-fragment,写清怎么往前 + 用户输入用法(大纲扩写或扮演停笔,勿混成半套)。 + 叙事指南:遣词/笔墨/禁忌/写法档——本步不定。 + 美学 / 交互范式:感觉与轮转权限。本步把「输入怎么用」写成游玩可执行的一种。 + 生成规则/变量*:合同与表——推进只写钩子,不写公式。 +feeds: gm,narrator +``` + +## opening + +```opening +美学里的轮转已有方向时,请谈「故事怎么往前」(想到什么写什么): + +1. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进? +2. 用户每轮输入怎么用?未提及时按体验二选一写入(可简短确认,不必当缺口追问): + - **大纲扩写**(写手/成稿向):输入=待扩写为事实的大纲;细化完全后写正文;不超出大纲未涉及的剧情,完整演绎大纲。 + - **扮演停笔**(代入扮演、等用户决策):用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;正文停在其他角色等用户角色回应之刻。 +``` + +## task + +```task +你正在执行「故事推进」。产物必须是 **context-fragment.v1** JSON,写入「设计.故事推进」。 + +钉清本局故事怎么往前、用户每轮输入怎么用。作用对象是主世界层与转述的游玩正文。 + +执行顺序: +1. 继承美学/交互范式里的轮转(何时等用户),改写成推进做法。 +2. 写推进与决策(可写「无专门推进」);变量只写钩子。 + 「用户输入用法」未提及时:按美学/交互范式选一种写入产物—— + - 写手/大纲成稿 → **大纲扩写**(输入=大纲→细化成正文;不越界、完整演绎); + - 代入扮演、轮转等用户决策 → **扮演停笔**(输入的行为与对话作正文开头并扩写填满;不写用户角色未给出的行为/对话;不替用户角色做决定;停在其他角色等回应之刻)。 + 两种都像则写进追问让用户点一次;不要混成半套规则。 +3. 自评 + 追问。遣词、笔墨、写法档交给「叙事指南」,本步不写。 + +summary:`故事推进 · …` +``` + +## principles + +```principles +1. 推进可轻可无;表规格交给变量/生成规则。 +2. 用户输入用法两套可选默认(未提及时择一,勿混用): + - 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。 + - 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。 +3. 交互范式已有停笔权限时:把它收成上面两种之一写入产物,不要因为权限已有就不落本步。 +``` + +## probe + +```probe +本步只钉两件:推进方式(场面推进 / 用户决策 / 无专门推进)、用户输入用法(大纲扩写 / 扮演停笔)。 + +用户未提输入用法时:按体验选一种写入产物并简短确认;两者都像才追问二选一,不必当成大缺口。 +推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。 + +缺其中一件:在产物「追问」写 1~2 点。两件够验收:`追问.题目` 为 `[]`。 + +用户谈到遣词、笔墨、写法档:写入「开放问题」,交给「叙事指南」。 +用户谈到变量增减、生成规则:只抽取能改推进方式的一句,其余写入「开放问题」。 +``` + +## output + +```output +{ + "schema": "context-fragment.v1", + "技能": "故事推进", + "brief": "一句话:推进有无 + 输入用法", + "mount": ["world-simulator", "narrator"], + "稳变": "stable", + "正文": { + "依据的体验": ["…"], + "推进与决策": { + "有无专门推进": "有|无", + "驱动力来源": "…", + "用户输入用法": "择一写入。大纲扩写:输入=待扩写为事实的大纲;细化后写正文;不超出大纲未涉及处,完整演绎。扮演停笔:用户本轮行为与对话作正文开头并扩写填满;不可写用户角色未给出的其它行为/对话;不可替用户角色做决定;停在其他角色等用户角色回应之刻。用户另定则改写", + "叙事速度与详略": "…", + "成败与难度": "…", + "变量门控钩子": [] + } + }, + "自评": { + "维度": [ + { "名": "推进可执行", "分数": 8, "说明": "…" }, + { "名": "输入用法钉清", "分数": 8, "说明": "…" } + ], + "薄弱点": "" + }, + "追问": { "导语": "", "题目": [] }, + "开放问题": [] +} +``` + +硬规则:合法 JSON;输入用法必须是大纲扩写或扮演停笔之一(或用户另定的完整写法),禁止半套;禁止 order;summary 见 task。 +不写遣词示范、写法档、禁忌清单。 + +## checklist + +```checklist +- [ ] 推进清楚或明确无?输入用法是完整的一种? +- [ ] 未把遣词/写法档写进本步? +``` + +## examples + +```examples +好:推进写「等用户决策」;输入用法钉「扮演停笔」。 +好:推进写「无专门推进」;输入用法钉「大纲扩写」,并写清不越界、一次成篇。 +坏:把两套输入用法混成半套;追问好感公式 / 事件池;或在本步写遣词示范。 +``` diff --git a/skills/dialogue/world-simulator/modules/topology/prompt.md b/skills/dialogue/world-simulator/modules/topology/prompt.md index 5539543..a21cdba 100644 --- a/skills/dialogue/world-simulator/modules/topology/prompt.md +++ b/skills/dialogue/world-simulator/modules/topology/prompt.md @@ -25,7 +25,7 @@ when_not: | boundary: | 本技能:用拓扑结构写出必须存在、又不走生成规则合同的内容(节点、连接、方向/层级)。 生成规则 / 具体实例:复杂合同与 records——本步不写生成格式、不填名片表。 - 舞台骨架:活动区域写细——本步不重写舞台百科。 + 舞台骨架:社会结构与活动区(谁在台上、区怎么划)——有节点与连接才走本步;仅格局叙述、无边可画 → 舞台骨架。 游玩拓扑:固定槽勾选——本步不描述执行单元谁读谁写。 变量*:跨轮状态——本步不设计变量表。 feeds: gm @@ -65,6 +65,7 @@ summary:`拓扑图谱 · {名称或类型}`。 2. 拓扑很泛:升级路径、地图、人物关系都是同一类产物,不要只当成探索地图。 3. 节点短、连接准。点不是名片,连接不是散文,也不是生成合同。 4. 该用规则反复生成的对象族不要塞进本步。 +5. 仅有「谁在台上、如何分层」的格局叙述、画不出边 → 交给舞台骨架,本步不写。 ``` ## probe @@ -161,5 +162,6 @@ summary:`拓扑图谱 · {名称或类型}`。 好:功法一阶接到二阶、旁支接到某秘术——升级路径。 坏:其实该按规则反复生成的对象族(如心之彩)硬写成拓扑。 坏:把舞台活动区再抄一遍,没有连接。 +坏:只有「谁在台上、如何分层」的格局散文、画不出边 → 舞台骨架,不是本步。 坏:描述主世界层谁读谁写 → 游玩拓扑,不是本步。 ``` diff --git a/skills/dialogue/world-simulator/modules/worker-spec/prompt.md b/skills/dialogue/world-simulator/modules/worker-spec/prompt.md index a099841..568ccb9 100644 --- a/skills/dialogue/world-simulator/modules/worker-spec/prompt.md +++ b/skills/dialogue/world-simulator/modules/worker-spec/prompt.md @@ -28,7 +28,7 @@ boundary: | 不再设机遇裁定(chance)槽:真随机走「随机范围整理」+ 程序插入上下文备用数。 细化终稿:按本步勾选展开 workers、合并常驻与 tables。 变量设计 / 变量控制上下文:真值与投影,不是推理槽。 - 叙事指南与故事推进:默认挂主世界层;若开了转述可挂转述(推进可兼挂主世界层);世界/机制:挂到主世界层——本步只点名槽。 + 叙事指南:默认挂主世界层;若开了转述可挂转述。故事推进挂主世界层(有转述可兼挂)。工序编排二者合为「叙事指南与故事推进」。世界/机制:挂到主世界层——本步只点名槽。 ``` ## opening @@ -118,7 +118,7 @@ boundary: | }, "mount_notes": [ "生成规则/变量合同/旁观摘要 → auditor(仅开旁观时;无长对话史)", - "叙事指南与故事推进 → gm(默认);若开转述可挂 narrator", + "叙事指南 / 故事推进(工序编排合为叙事指南与故事推进)→ gm(默认);若开转述可挂 narrator", "世界/机制/变量规则 → gm", "随机范围整理 → gm(上下文备用随机数;无机遇槽)", "真值与 side_effects → 细化终稿 tables" diff --git a/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md b/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md index b7746b5..b1a7e4d 100644 --- a/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md +++ b/skills/dialogue/world-simulator/modules/world-blueprint/prompt.md @@ -17,13 +17,15 @@ when: | 玩家实际游玩过程中的主要活动区域必须写细,但如果本身世界变造不多、或特殊设定较少(如西幻就足以概括的世界),就不需要多写。 when_not: | 整体感觉未钉 → 先「美学纲领与交互范式」。 - 代入后几乎不移动:玩家只会在一处待着体验,不会走到别的活动区域(如网聊、门卫室)→ 不排。 - 可点名名片 → 「具体实例」另排。持续生成合同 → 「生成规则」。 + 既少换区、社会格局与世界状况也不需要备案才能撑体验(如网聊、门卫室)→ 不排。 + 虽少换区,但台上势力/运转状况仍决定体验(如一室朝堂博弈)→ 仍排;活动区可极简成一处。 + 可点名名片 → 「具体实例」另排。对象族怎么生成 → 「生成规则」。 boundary: | 本技能:舞台备案——范围、基底+变造、主要活动区域(写细)、社会结构、世界状况。 + 多张名片/同批实例共享的台上格局与世界状况写在这里,供下游引用。 美学纲领与交互范式:体验与轮转——本步不重定。 实现机制:既成变造法则——本步把法则落到活动区域上长什么样,不重钉法则来源。 - 具体实例 / 生成规则 / 叙事指南与故事推进:名片、生成节奏、遣词与推进——本步不定。 + 具体实例 / 生成规则 / 叙事指南:名片、生成合同(schema/池/描写法)、遣词——本步不定。故事推进(工序编排并入「叙事指南与故事推进」)也不定。 feeds: gm ``` @@ -83,6 +85,7 @@ summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况) 地理只是一类钩子。社会结构(谁在台上、如何分层互动)与世界状况(寒灾、异界出口、活人祭祀、命脉资源等) 往往比「哪条河接哪座山」更能决定故事如何运转——必须写,但不能写成物种志/民俗全书。 可以是抽象格局(对峙、割据、阶层天井)。不要求接壤比例尺、全史年表。 + 下游要生成多张名片时:共享的台上格局与状况仍写在本步;生成规则只引用,不在规则里重抄世界书。 下游需要点名条目时交给「具体实例」;本步给格局级与状况级引用钩子。 3. 变造基底,再按活动区写细 @@ -269,5 +272,6 @@ summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况) - 只写山脉河流,不写祭祀/灾变/异界口等用户已点名的运转部件。 - 「先写完整七大国、五十种魔族生态、三万年神话…」→ 百科。 - 「地点1:XX咖啡馆,店主叫…」→ 具体实例,不是骨架。 +- 把多张名片共享的台上格局丢给生成规则去写「共通前提」→ 本步该写格局钩子,规则只引用。 - 整份只有散文无三段外壳。 ``` diff --git a/skills/dialogue/world-simulator/recipes/catalog.yaml b/skills/dialogue/world-simulator/recipes/catalog.yaml index c6b4481..c773cc4 100644 --- a/skills/dialogue/world-simulator/recipes/catalog.yaml +++ b/skills/dialogue/world-simulator/recipes/catalog.yaml @@ -1,4 +1,4 @@ -# 节点流程 · 配方目录(与转述进料配方不共享) +# 工序编排 · 配方目录(与对话落盘配方不共享) # id = recipes/{id}/ 文件夹 # name = 固定中文名(下拉展示) # declaration = 给人看的短说明 @@ -8,8 +8,8 @@ recipes: - id: world-simulator - name: 世界模拟器 - declaration: 回合互动、世界推进、角色扮演类体验的设计方法 + name: 回合推演 + declaration: 回合互动、可扩展的世界推进与角色扮演;适合要长期经营、能往外长设定的体验 - id: expand-assistant name: 扩写助手 diff --git a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml index 3800d36..1dfaf13 100644 --- a/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/world-simulator/recipe.yaml @@ -1,9 +1,9 @@ -# 世界模拟器 · 配方 +# 回合推演 · 配方(内部 id:world-simulator;旧称世界模拟器) # 写方法论:适用、核心思路、设计流程、原则。 # 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。 # steps = 开局起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步。 -when: 回合互动、世界推进、角色扮演、沉浸推演类体验 +when: 回合互动、可扩展的世界推进、角色扮演、沉浸推演类体验 core: >- 先钉清用户如何参与、正文如何呈现、核心体验与禁忌(美学卡上用户会在意的部分); @@ -32,7 +32,7 @@ principles: - 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。实现机制对照其 meta。 - 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。 -brief: 世界模拟类:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格 +brief: 回合推演:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格 steps: - id: 美学纲领与交互范式 diff --git a/skills/dialogue/world-simulator/recipes/转述/catalog.yaml b/skills/dialogue/world-simulator/recipes/转述/catalog.yaml index 79a91d7..24612ac 100644 --- a/skills/dialogue/world-simulator/recipes/转述/catalog.yaml +++ b/skills/dialogue/world-simulator/recipes/转述/catalog.yaml @@ -1,8 +1,11 @@ -# 转述进料 · 配方目录(与节点流程配方不共享) +# 对话落盘 · 配方目录(与工序编排配方不共享) # id = recipes/转述/{id}/ 文件夹;对用户展示用 name(中文) -# 选定后写入黑板 tag 创作.选用配方;转述模式不种节点 DAG +# 选定后写入黑板 tag 创作.选用配方;对话落盘不种节点 DAG recipes: + - id: 快穿短局 + name: 快穿短局 + declaration: 围着一个核心设定快速代入;难扩展、不适合长期经营;至多简单数值 - id: 数据化跑团体验 name: 数据化跑团体验 declaration: AIRP / 交互式小说;设定为主,可选跨轮真值与分档映射 diff --git a/skills/dialogue/world-simulator/recipes/转述/交互式长文生成器/recipe.yaml b/skills/dialogue/world-simulator/recipes/转述/交互式长文生成器/recipe.yaml index 3bef329..028bca7 100644 --- a/skills/dialogue/world-simulator/recipes/转述/交互式长文生成器/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/转述/交互式长文生成器/recipe.yaml @@ -1,4 +1,4 @@ -# 交互式长文生成器 · Boss 直聘进料配方 +# 交互式长文生成器 · 对话落盘配方 # 原「文本生成器」长文路径;多轮互动钉规格,再生成连贯长文。 when: >- @@ -10,8 +10,8 @@ core: >- 默认无变量、无旁观、无主世界层。正文格式(设计.正文组成)与开场白按需另钉。 process: - - 听清写什么、给谁看、气质与禁忌 → insert 用户.需求 / 设计.文风 等(order 偏小)。缺关键信息就问一句。 - - 需要版式 → insert 设计.正文组成;要开篇样例 → 按格式 insert 设计.开场白。 + - 听清写什么、给谁看、气质与禁忌 → insert 用户.需求 / 设计.文风 等(order 偏小)。本轮材料已够时可同轮多落;缺关键信息就问一句。 + - 需要版式 → insert 设计.正文组成;要开篇样例 → 按格式 insert 设计.开场白。上游已齐时可与上列同轮一并落。 - 不要搭跑团数值、舞台骨架、declare_variable。 - 长章叙事落产物,不要只留在聊天确认段。 diff --git a/skills/dialogue/world-simulator/recipes/转述/快穿短局/recipe.yaml b/skills/dialogue/world-simulator/recipes/转述/快穿短局/recipe.yaml new file mode 100644 index 0000000..eda6e57 --- /dev/null +++ b/skills/dialogue/world-simulator/recipes/转述/快穿短局/recipe.yaml @@ -0,0 +1,36 @@ +# 快穿短局 · 对话落盘配方 +# 注入系统提示「本局配方」;不种节点 DAG。 +# 各能力何时落盘 → 【能力 · 何时落盘】;本配方只给骨架,并覆盖「舞台/生成/拓扑」即使何时用成立也不落。 + +when: >- + 用户要快速代入一小段体验:快穿小世界、一次性副本、单钩子情景、短任务。 + 核依赖跨轮记住时,至多两三个数字(时限、任务进度、好感)。 + +core: >- + 把用户原话收成可开玩的一核短局规格,写入本局黑板;开玩后主世界层读产物写正文,默认原文即终稿。 + 必交:美学纲领(这一核是什么、怎么代入、轮转到哪停)。 + 用户代入有名字/简介 → 写进开场白的用户角色(meta:名字+简介),不要另开主角设定。 + 核是拿掉会换故事的变造法则 → 再交实现机制。 + 核依赖跨轮记住 → 再 declare 至多两三个真值。核可玩 → 交开场白。 + 某一能力能不能落对照【能力 · 何时落盘】。材料只够围着这一核开玩 → 收口开场白。 + 用户改口要铺可经营世界 → 本局仍把已说的收成这一核,并告知完整世界用「数据化跑团体验」或工序编排「回合推演」。 + +process: + - 每轮对照【能力 · 何时落盘】与已有产物:条件已成立的可同轮多落;核还糊 → 问挡住的 1 点。 + - 【1】insert 设计.美学纲领与交互范式:用用户原话写清这一核、站位、何时等用户。缺哪件问哪件。 + - 【1b】代入且有名字/简介 → 写进开场白 meta 的用户角色(名字+简介),不要单独 insert 设计.主角设定。 + - 【2】核=拿掉会换故事的变造法则(如任务失败即送走)→ insert 设计.实现机制,写短。只是身份或开场处境 → 写进开场白或主角设定,跳过机制。 + - 用户点名文风/禁忌/写法档 → insert 叙事指南。点名推进/大纲/停笔 → insert 故事推进。二者条件独立,不要因为没谈推进就不落文风。用户要分区或壳 → 回复呈现再正文组成。未点名文风与推进 → 跳过二者,开玩吃默认散文。材料已齐且条件成立时可与上列同轮一并落。 + - 核必须跨轮记住的数(时限/进度/好感,至多三个)→ declare_variable;到某值换文案再 declare_map。没有门控 → 不立真值。 + - 核已可玩 → insert 设计.开场白(须含 @玩家;第一屏只写这一核怎么开始)。 + - 舞台骨架 / 生成规则 / 拓扑即使「何时用」成立也不 insert:地点与规则折进美学核或开场白。 + +principles: + - 落盘只留删掉会让「这局玩什么」变掉的内容;其余当随口补充,问一句要不要并进核,用户没要就不 insert。 + - 交得出可玩开场即停。地图、分册、生成一族 NPC 留给「数据化跑团体验」或「回合推演」。 + - 有门控才 declare 真值。 + - 默认只开主世界层;有真值时旁观由落档程序开。规格只走 toolcall。 + +brief: 一核短局:钉体验核 → 主角名片 → 按需一条机制/两三个数 → 开场开玩 + +steps: [] diff --git a/skills/dialogue/world-simulator/recipes/转述/数据化跑团体验/recipe.yaml b/skills/dialogue/world-simulator/recipes/转述/数据化跑团体验/recipe.yaml index bb76ffd..3e9671f 100644 --- a/skills/dialogue/world-simulator/recipes/转述/数据化跑团体验/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/转述/数据化跑团体验/recipe.yaml @@ -1,4 +1,4 @@ -# 数据化跑团体验 · Boss 直聘进料配方 +# 数据化跑团体验 · 对话落盘配方 # 注入转述总助手「本局配方」;不种节点 DAG。 # 名字带「数据化」,内核仍是 AIRP / 交互式小说。 # 各能力何时落盘 → 系统提示【能力 · 何时落盘】(modules meta);本配方只给顺序骨架。 @@ -12,14 +12,15 @@ core: >- 再按需用核心规则(实现机制)钉下那套特殊故事设计;然后才往外兑现舞台、 叙事、真值、映射、版式与开场。作用对象是本局黑板;开玩后主世界层读投影与 真值写正文,默认只开主世界层、原文即终稿。 - 具体某一刀能不能落:对照【能力 · 何时落盘】的何时用 / 何时不用;本配方不重复罗列。 + 具体能不能落:对照【能力 · 何时落盘】的何时用 / 何时不用;本配方不重复罗列。 process: - - 每轮先对照【能力 · 何时落盘】与已有产物:只推进「何时用」已成立的一刀;条件含糊或未到 → 短确认或问挡住的 1 点,不批量 insert / declare。 + - 每轮对照【能力 · 何时落盘】与已有产物:凡「何时用」已成立的可同轮多次 insert / declare_*;条件含糊或未到 → 本轮不落该能力,短确认或问挡住的 1 点。不抢跑未齐条件的下游。 - 【骨架序 · 1】美学纲领与体验核心(能力表里通常总是先做)→ insert 设计.美学纲领与交互范式。模糊则细化;不确定则询问。未锚定前不建议下游。 - 【骨架序 · 2】实现机制(仅当能力「何时用」成立:有拿掉就换骨架的变造法则)→ insert 设计.实现机制。何时不用成立则跳过,不追问要不要加规则。 - - 【骨架序 · 3+】其余能力(舞台、生成规则、叙事、变量、回复呈现、正文组成、开场白…)各自对照何时用再落;变量走 declare_variable / declare_map。一次一两刀。 - - 聊完一轮:短确认本轮落了什么 + 建议「条件已近」的下一刀(勿推荐尚未满足何时用的能力)。 + - 【骨架序 · 3+】其余能力(主角设定、舞台、生成规则、叙事、变量、回复呈现、正文组成、开场白…)各自对照何时用再落;变量走 declare_variable / declare_map。本轮材料已够且条件齐的可一并落。 + 用户代入且有名字/身份/金手指时落主角设定(insert 设计.主角设定)。 + - 聊完一轮:按 reply_module 分段确认本轮所落模块 + 建议「条件已近」的下一刀(勿推荐尚未满足何时用的能力)。 principles: - 锚定优先:美学不定稳,不做下游;完善设定时不要忘掉核心体验。 diff --git a/skills/dialogue/world-simulator/recipes/转述/文本生成器/recipe.yaml b/skills/dialogue/world-simulator/recipes/转述/文本生成器/recipe.yaml index f01615a..3c82696 100644 --- a/skills/dialogue/world-simulator/recipes/转述/文本生成器/recipe.yaml +++ b/skills/dialogue/world-simulator/recipes/转述/文本生成器/recipe.yaml @@ -1,4 +1,4 @@ -# 文本生成器 · Boss 直聘进料配方 +# 文本生成器 · 对话落盘配方 # 创作期写出游玩要用的固定上下文;成文在游玩:主世界出正文,转述出文风/壳。 when: >- @@ -6,16 +6,17 @@ when: >- 篇与篇不必剧情承接;成文走游玩管线(主世界 + 叙事转述) core: >- - 创作进料必须 insert 齐游玩上下文:模仿范例、模仿要点、叙事指南(大纲扩写)、可选轻量需求。 + 创作进料必须 insert 齐游玩上下文:模仿范例、模仿要点、故事推进(大纲扩写)、可选叙事指南与轻量需求。 落档只挂载这些创作期产物,不另造用法。开玩后主世界写故事正文(Markdown),转述按范例改写/镶壳。 - 刚需开转述;用户输入用法以创作期叙事指南为准。 + 刚需开转述;用户输入用法以创作期故事推进为准。 process: - 要齐:① insert 设计.模仿范例;② insert 设计.模仿要点(用户点名要学的面)。缺则问这两项。 - - 必 insert 设计.叙事指南与故事推进(创作期产物,开玩直接挂载)。正文至少写清: + - 必 insert 设计.故事推进(创作期产物,开玩直接挂载)。正文至少写清: 「用户输入用法:大纲扩写。本轮输入=短文大纲/场面要点;一次成完整篇并收束。禁止扮演停笔、禁止等用户行动。主世界交故事正文;有转述时转述负责文风与呈现,可补残稿但不改剧情。」 + - 文风细则对照「叙事指南」何时用再 insert 设计.叙事指南。 - 可选 insert 用户.需求(题材/禁忌/篇幅)。 - - 齐料清单缺叙事指南 / 范例 / 要点任一 → 先补 insert,不要说可以开玩。 + - 齐料清单缺故事推进 / 范例 / 要点任一 → 先补 insert,不要说可以开玩。 - 材料齐了:告知「可以落档/开玩」;禁止在创作期写成稿短文。 - 开玩:用户本轮=本篇大纲;读创作期已挂上下文推进。对话一长 clear_dialogue,留产物。 @@ -23,9 +24,9 @@ principles: - 游玩只消费创作期产物;用法/文风/模仿面都在 insert 里,不靠落档程序补种。 - 创作≠生产:创作收上下文;生产在游玩。 - 分工:主世界 = 故事正文;转述 = 文风/壳(对照范例+要点;可补残稿)。 - - 输入用法由创作期叙事指南钉死为大纲扩写。 + - 输入用法由创作期故事推进钉死为大纲扩写。 - 一篇一世界;少问约束;无 declare_variable。 -brief: 文本生成:创作期钉范例+要点+大纲扩写指南 → 开玩挂载成文 +brief: 文本生成:创作期钉范例+要点+大纲扩写推进 → 开玩挂载成文 steps: [] diff --git a/skills/dialogue/world-simulator/worker-templates/narrator.yaml b/skills/dialogue/world-simulator/worker-templates/narrator.yaml index 3084336..e44219e 100644 --- a/skills/dialogue/world-simulator/worker-templates/narrator.yaml +++ b/skills/dialogue/world-simulator/worker-templates/narrator.yaml @@ -8,6 +8,8 @@ when: 主世界层本轮正文已写入后 suggested_context: static: - 设计.叙事指南与故事推进 + - 设计.叙事指南 + - 设计.故事推进 - 设计.正文组成 - 设计.监控栏 dynamic: @@ -39,9 +41,9 @@ prompt_excerpt: | monitor 用短键值(芯片),勿写长文。只填该壳已有区域。 用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。 采用完整长自然段叙述,不要单句成段;不要几句短信就结束。 - 用户输入用法以「设计.叙事指南与故事推进」为准: + 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准: - 大纲扩写:把主世界正文当事实底稿,细化文风后写完整终稿;不超出底稿未涉及的剧情;一次成篇,勿停等用户行动。 - - 扮演停笔:仅当叙事指南明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。 - - 未钉叙事指南时:按大纲扩写,禁止默认扮演停笔。 + - 扮演停笔:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。 + - 未钉故事推进/叙事指南时:按大纲扩写,禁止默认扮演停笔。 禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。 禁止编造主世界正文中没有的事实。 diff --git a/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml b/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml index 3e70c25..d857262 100644 --- a/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml +++ b/skills/dialogue/world-simulator/worker-templates/opening-generator.yaml @@ -21,5 +21,5 @@ suggested_outputs: - 运行.初始变量 - 变量.当前 prompt_excerpt: | - 若有设计.开场白与开场变量:落库开场白全文与开场变量,遵守正文组成版式;保留字面 @玩家。 - 若无:现写开场(正文须含 @玩家),表与开场同真相。禁止先填表再糊开场。 + 若有设计.开场白与开场变量:落库推荐条 present.v1 正文与开场变量;保留字面 @玩家。 + 若无:现写开场(present.v1,shell 与正文组成一致;正文须含 @玩家),表与开场同真相。禁止先填表再糊开场。 diff --git a/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml index c239d22..fdad8f8 100644 --- a/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml +++ b/skills/dialogue/world-simulator/worker-templates/world-simulator.yaml @@ -11,6 +11,8 @@ suggested_context: - 用户.需求 - 设计.变量设计与更新规则 - 设计.叙事指南与故事推进 + - 设计.叙事指南 + - 设计.故事推进 - 设计.正文组成 - 设计.监控栏 dynamic: @@ -28,10 +30,10 @@ prompt_excerpt: | 无独立转述时:你写的正文就是用户终稿。 若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。 真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。 - 用户输入用法以「设计.叙事指南与故事推进」为准: + 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准: - 钉了「大纲扩写」:本轮输入=待扩写大纲/场面要点;一次交完整篇正文,勿停在等用户行动处。 - 钉了「扮演停笔」:才只推进用户已声明行为/对话,并停在需用户回应之刻。 - - 未钉叙事指南时:按大纲扩写处理,禁止默认扮演停笔。 + - 未钉故事推进/叙事指南时:按大纲扩写处理,禁止默认扮演停笔。 需要随机数时:只用本轮上下文里程序已插入的备用随机数(见「设计.随机范围整理」);禁止口算编造。 不要把未投影的分档长文整表塞进输出;只写本轮可见后果。 禁止在正文里写出 tag 名、压缩摘要或过程日志。 diff --git a/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md index ee25e9a..3096a87 100644 --- a/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md +++ b/skills/dialogue/world-simulator/workers/opening-generator/SKILL.md @@ -42,11 +42,11 @@ contextSegments: 创作末尾兜底(DAG **没有**「开场白与开场变量」时才需要本 worker)。 规格已定。优先读取 **`设计.开场白与开场变量`**: -- 若已有且可用:把其中「开场白全文」写入 `输出.开场白`,把「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。微调时仍须保留字面 `@玩家`。 +- 若已有且可用:把其中推荐条的 present.v1「正文」写入 `输出.开场白`(JSON 字符串),把该条 meta「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。微调时仍须保留字面 `@玩家`,且保持 present.v1。 - 若无:再站在 Worker 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。 ```text -主产物:输出.开场白(用户读的第一幕;须符合正文组成版式;须含字面 @玩家) +主产物:输出.开场白(用户读的第一幕;**present.v1**,shell 与正文组成一致;须含字面 @玩家) 辅产物:运行.初始变量 / 变量.当前(与开场同一真相) ``` @@ -57,7 +57,7 @@ contextSegments: 你是开场落库者(兼必要时的开场作者): 1. 先读 `设计.开场白与开场变量`(若有),再读 Worker 集 / 定稿摘要 / 用户需求 -2. **落库或写出开场白**:遵守叙事指南与正文组成;用户角色名位写字面 `@玩家` +2. **落库或写出开场白**:遵守叙事指南与正文组成;`输出.开场白` 必须是 present.v1 JSON(shell 与「设计.正文组成」一致);用户角色名位写字面 `@玩家` 3. **落表**:初值与开场事实一致;能推断则推断 ## 重心(务必遵守) @@ -132,7 +132,7 @@ contextSegments: ```json { "outputs": { - "输出.开场白": "……须含字面 @玩家……", + "输出.开场白": "{\"schema\":\"present.v1\",\"shell\":\"与正文组成一致\",\"blocks\":{\"body\":\"……须含字面 @玩家……\"},\"meta\":{\"suggested_actions\":[]}}", "运行.初始变量": "{ ... rows ... }", "变量.当前": "{ ... 与初始一致 ... }" }, diff --git a/src/blackboard/blackboard.ts b/src/blackboard/blackboard.ts index 8283a5a..7b19bb8 100644 --- a/src/blackboard/blackboard.ts +++ b/src/blackboard/blackboard.ts @@ -102,6 +102,20 @@ export class Blackboard { return item; } + /** 删除某 tag 的全部条目;返回是否曾存在 */ + deleteByTag(tag: string): boolean { + const t = tag.trim(); + if (!t) return false; + let removed = false; + for (const [id, item] of this.items) { + if (item.tag === t) { + this.items.delete(id); + removed = true; + } + } + return removed; + } + seed(items: BlackboardItem[]): void { for (const item of items) { this.items.set(item.id, item); diff --git a/src/blackboard/table-cells.ts b/src/blackboard/table-cells.ts index 6afaace..28b65ac 100644 --- a/src/blackboard/table-cells.ts +++ b/src/blackboard/table-cells.ts @@ -6,10 +6,13 @@ export type TableCellSource = `user` | `system` | `worker:${string}`; export type TableCell = { key: string; value: unknown; + /** 该格版本号:手改须带读到的 rev,对不上则拒绝,避免盖过后端更新 */ rev: number; updatedAt: string; source: TableCellSource | string; visibility?: "visible" | "hidden"; + /** 未声明时:可见格可改,隐藏格只读 */ + editable?: boolean; note?: string; }; @@ -47,6 +50,12 @@ export function parseTableDoc(raw: string | undefined | null): TableDoc | null { row.visibility === "hidden" || row.visibility === "visible" ? row.visibility : "visible", + editable: + row.editable === true + ? true + : row.editable === false + ? false + : undefined, note: typeof row.note === "string" ? row.note : undefined, }); } @@ -109,6 +118,7 @@ export function mergeTableCells(params: { updatedAt: now, source: params.actor, visibility: patch.visibility ?? existing?.visibility ?? "visible", + editable: patch.editable ?? existing?.editable, note: patch.note ?? existing?.note, }); applied.push(key); @@ -121,11 +131,108 @@ export function mergeTableCells(params: { }; } +export function isTableCellEditable(row: TableCell): boolean { + if (row.editable === false) return false; + if (row.editable === true) return true; + return (row.visibility ?? "visible") === "visible"; +} + +export type TableCellPatch = { + key: string; + value: unknown; + expectedRev: number; +}; + +export type UserTableSkip = { + key: string; + reason: string; + have?: number; + expected?: number; + value?: unknown; +}; + +/** 用户按格手改:必须带读到的 rev;不可改或版本冲突则跳过该格。 */ +export function patchUserTableCells( + current: TableDoc | null, + patches: readonly TableCellPatch[], +): { + doc: TableDoc; + applied: string[]; + skipped: UserTableSkip[]; +} { + const empty: TableDoc = { rows: [] }; + const base = current ?? empty; + const skipped: UserTableSkip[] = []; + const allowed: TableCellPatch[] = []; + for (const patch of patches) { + const key = patch.key.trim(); + if (!key) continue; + const existing = base.rows.find((r) => r.key === key); + if (!existing) { + skipped.push({ key, reason: "missing" }); + continue; + } + if (!isTableCellEditable(existing)) { + skipped.push({ + key, + reason: "not-editable", + have: existing.rev, + value: existing.value, + }); + continue; + } + if (!Number.isFinite(patch.expectedRev)) { + skipped.push({ key, reason: "rev-required", have: existing.rev }); + continue; + } + allowed.push({ ...patch, key }); + } + if (!allowed.length) { + return { doc: base, applied: [], skipped }; + } + const expectedRev = Object.fromEntries( + allowed.map((p) => [p.key, p.expectedRev]), + ); + const merged = mergeTableCells({ + current: base, + patch: { + rows: allowed.map((p) => ({ + key: p.key, + value: p.value, + rev: 1, + updatedAt: new Date().toISOString(), + source: "user", + })), + }, + actor: "user", + expectedRev, + }); + const byKey = new Map(base.rows.map((r) => [r.key, r])); + const skippedOut: UserTableSkip[] = [ + ...skipped, + ...merged.skipped.map((s) => { + const haveRow = byKey.get(s.key); + const expected = expectedRev[s.key]; + return { + key: s.key, + reason: s.reason, + have: haveRow?.rev, + expected, + value: haveRow?.value, + }; + }), + ]; + return { doc: merged.doc, applied: merged.applied, skipped: skippedOut }; +} + /** 从零创建:全部 source=actor,rev=1 */ export function createTableFromValues( values: Record, actor: TableCellSource | string, - meta?: Record, + meta?: Record< + string, + { visibility?: "visible" | "hidden"; note?: string; editable?: boolean } + >, ): TableDoc { const now = new Date().toISOString(); const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({ @@ -135,6 +242,9 @@ export function createTableFromValues( updatedAt: now, source: actor, visibility: meta?.[key]?.visibility ?? "visible", + ...(meta?.[key]?.editable !== undefined + ? { editable: meta[key]?.editable } + : {}), note: meta?.[key]?.note, })); return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) }; diff --git a/src/dictate/index.ts b/src/dictate/index.ts index e5368fd..4eb6c50 100644 --- a/src/dictate/index.ts +++ b/src/dictate/index.ts @@ -19,6 +19,17 @@ export { } from "./context.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { runDictateTurn, type DictateTurnResult } from "./turn.js"; +export { + DICTATE_INSERT_TAG_ALIASES, + DICTATE_MULTI_MODULE_REPLY_HINT, + buildDictateInsertFeedback, + buildDictateInsertFeedbackIndex, + formatDictateUserFacingBrief, + lookupDictateInsertFeedback, + resolveModuleForInsertTag, + extractSelfScoreDimensionNames, + type DictateInsertFeedback, +} from "./insert-feedback.js"; export { applyDictatePlayBind, bindDictateProductsToPlaySpec, @@ -26,6 +37,18 @@ export { collectDictateBindProducts, type DictatePlayBindResult, } from "./play-bind.js"; +export { + REPEATABLE_TAG_SEP, + buildRepeatableProductTag, + collectFamilyContents, + dictateProductFamily, + dictateProductSlot, + extractRepeatableSlotFromContent, + isDictateProductFamily, + mergeGenerationRulesArtifacts, + resolveRepeatableInsertTag, + sanitizeRepeatableSlot, +} from "./repeatable-tags.js"; export { VARIABLE_CATALOG_TAG, parseVariableCatalog, diff --git a/src/dictate/insert-feedback.ts b/src/dictate/insert-feedback.ts new file mode 100644 index 0000000..d7e791f --- /dev/null +++ b/src/dictate/insert-feedback.ts @@ -0,0 +1,210 @@ +/** + * insert(tag) → 反查能力 → 独立「回复模块」规范,塞进 tool result。 + * 一轮可多次 insert:每个模块各自一份;末尾回复须分段全部附带。 + * 不照搬 schema / mount;只抽 probe、交互原则、自评关注点。 + */ + +import { + getModuleSection, + loadModulePrompt, + parseModulePromptSections, + type ModuleCatalog, + type ModuleCatalogEntry, +} from "../skills/creation-flow.js"; + +/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */ +export const DICTATE_INSERT_TAG_ALIASES: Readonly> = { + "设计.开场白": "opening-setup", + "设计.美学纲领": "aesthetics-interaction", + "设计.文风": "narrative-guide", + "设计.监控栏": "status-bar", +}; + +/** 多模块同轮:写进每条 insert tool result 的 reply_hint */ +export const DICTATE_MULTI_MODULE_REPLY_HINT = + "本轮带回了多个 reply_module。末尾可见回复:每个模块各写一块 markdown,标题用 ## 模块 · {名称}(与该 reply_module 一致);块间空一行;每块只写该模块的确认 / 薄弱点 / 追问。全部模块写完后,可另起一段写 1 条「建议下一刀」(条件须已接近成立)。缺某一块则补上后再结束本轮。"; + +const MAX_PROBE_CHARS = 1200; +const MAX_PRINCIPLES_CHARS = 900; +const MAX_BRIEF_CHARS = 2800; + +export type DictateInsertFeedback = { + moduleId: string; + moduleName: string; + tag: string; + /** 给模型的独立模块 markdown */ + text: string; +}; + +export function resolveModuleForInsertTag( + tag: string, + catalog: ModuleCatalog | null | undefined, +): ModuleCatalogEntry | null { + const t = tag.trim(); + if (!t || !catalog?.modules?.length) return null; + + const byArtifact = catalog.modules.find((m) => m.artifact?.trim() === t); + if (byArtifact) return byArtifact; + + // 可增殖拆分:设计.生成规则#slug → 仍反查 设计.生成规则 + const hash = t.indexOf("#"); + if (hash > 0) { + const family = t.slice(0, hash).trim(); + const byFamily = catalog.modules.find((m) => m.artifact?.trim() === family); + if (byFamily) return byFamily; + } + + const aliasId = DICTATE_INSERT_TAG_ALIASES[t]; + if (aliasId) { + return catalog.modules.find((m) => m.id === aliasId) ?? null; + } + return null; +} + +/** 从 output 块里抽自评维度名(用户向;不交 JSON) */ +export function extractSelfScoreDimensionNames(outputFence: string): string[] { + const names: string[] = []; + const re = /"名"\s*:\s*"([^"]+)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(outputFence)) !== null) { + const name = m[1]!.trim(); + if (name && !names.includes(name)) names.push(name); + } + return names.slice(0, 8); +} + +function clip(text: string, max: number): string { + const t = text.trim(); + if (t.length <= max) return t; + return `${t.slice(0, max - 1)}…`; +} + +/** + * 从能力 prompt.md 抽「回答时附带本模块」的 markdown(无匹配块则空串)。 + */ +export function formatDictateUserFacingBrief(params: { + tag: string; + module: ModuleCatalogEntry; + promptMd: string; +}): string { + const sections = parseModulePromptSections(params.promptMd); + const probe = getModuleSection(sections, "probe"); + const principles = getModuleSection(sections, "principles"); + const output = getModuleSection(sections, "output"); + const dims = output ? extractSelfScoreDimensionNames(output) : []; + + if (!probe && !principles && dims.length === 0) return ""; + + const includes: string[] = []; + if (probe) includes.push("探测与追问口径"); + if (principles) includes.push("交互原则"); + if (dims.length) includes.push(`自评关注(${dims.join("、")})`); + + const parts: string[] = [ + `# 模块 · ${params.module.name}`, + "", + `回答时请附带本模块(落盘 tag:\`${params.tag}\`)。`, + "", + "本模块内容包括:", + ...includes.map((x) => `- ${x}`), + "", + "## 写入用户回复时做什么", + `1. 用二级标题 \`## 模块 · ${params.module.name}\` 开一段(多模块时本段只写本模块)。`, + "2. 确认:2~4 句复述本模块已落要点,让用户能判断对不对。", + "3. 薄弱点:对照下方「自评关注」口头点 1 处不够硬的地方(不必交打分表或 JSON)。", + "4. 追问:1~2 点;每点给建议选项或短场景,用户可直接采用或改写。材料已够则追问可空,改写「建议下一刀」。", + "5. 不粘贴产物全文;不复述 tool JSON。", + ]; + + if (probe) { + parts.push("", "## 探测与追问(本模块口径)", clip(probe, MAX_PROBE_CHARS)); + } + if (principles) { + parts.push("", "## 交互原则(摘)", clip(principles, MAX_PRINCIPLES_CHARS)); + } + if (dims.length) { + parts.push("", "## 自评关注", dims.map((d) => `- ${d}`).join("\n")); + } + + return clip(parts.join("\n"), MAX_BRIEF_CHARS); +} + +export function buildDictateInsertFeedback(params: { + tag: string; + catalog: ModuleCatalog | null | undefined; + promptMd: string | null | undefined; +}): DictateInsertFeedback | null { + const module = resolveModuleForInsertTag(params.tag, params.catalog); + if (!module || !params.promptMd?.trim()) return null; + const text = formatDictateUserFacingBrief({ + tag: params.tag.trim(), + module, + promptMd: params.promptMd, + }); + if (!text.trim()) return null; + return { + moduleId: module.id, + moduleName: module.name, + tag: params.tag.trim(), + text, + }; +} + +/** + * 预加载 catalog 全部能力的 insert 反馈索引(tag → 模块 markdown)。 + * 含 artifact 与别名 key。 + */ +export async function buildDictateInsertFeedbackIndex(params: { + catalog: ModuleCatalog; + skillPackRoot: string; + skillsRoot?: string; +}): Promise> { + const index = new Map(); + const { catalog, skillPackRoot } = params; + + await Promise.all( + catalog.modules.map(async (module) => { + const promptMd = await loadModulePrompt( + skillPackRoot, + module.id, + params.skillsRoot, + ); + if (!promptMd) return; + const artifact = module.artifact?.trim(); + if (artifact) { + const fb = buildDictateInsertFeedback({ + tag: artifact, + catalog, + promptMd, + }); + if (fb) index.set(artifact, fb.text); + } + for (const [aliasTag, moduleId] of Object.entries( + DICTATE_INSERT_TAG_ALIASES, + )) { + if (moduleId !== module.id) continue; + const fb = buildDictateInsertFeedback({ + tag: aliasTag, + catalog, + promptMd, + }); + if (fb) index.set(aliasTag, fb.text); + } + }), + ); + + return index; +} + +export function lookupDictateInsertFeedback( + index: Map | null | undefined, + tag: string, +): string | undefined { + const t = tag.trim(); + if (!t || !index?.size) return undefined; + const direct = index.get(t); + if (direct) return direct; + const hash = t.indexOf("#"); + if (hash > 0) return index.get(t.slice(0, hash).trim()); + return undefined; +} diff --git a/src/dictate/play-bind.ts b/src/dictate/play-bind.ts index 20a45b2..0556314 100644 --- a/src/dictate/play-bind.ts +++ b/src/dictate/play-bind.ts @@ -1,5 +1,5 @@ /** - * Boss 直聘式 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 + * 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 */ import type { Blackboard } from "../blackboard/blackboard.js"; @@ -59,7 +59,7 @@ const SKIP_BIND_TAGS = new Set([ /** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */ const STYLE_TAG_RE = - /叙事指南|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/; + /叙事指南|故事推进|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/; /** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */ const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]); @@ -85,7 +85,7 @@ function shouldSkipBindTag(tag: string): boolean { return false; } -/** 从黑板收集 Boss 直聘产物(有正文的 用户.* / 设计.*) */ +/** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */ export function collectDictateBindProducts(board: Blackboard): DictateProduct[] { const products: DictateProduct[] = []; for (const e of board.listTagIndex()) { @@ -162,7 +162,7 @@ function buildGmInserts(params: { o++, p.tag, "full", - `Boss直聘固定产物〔order=${effectiveDictateOrder(p)}〕`, + `对话落盘固定产物〔order=${effectiveDictateOrder(p)}〕`, ), ); } @@ -278,7 +278,7 @@ export function buildDictateContextOrder(params: { return renumberContextOrder({ schema: CONTEXT_ORDER_SCHEMA, - brief: "Boss直聘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入", + brief: "对话落盘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入", play_slots: params.playSlots, slots, }); @@ -325,7 +325,7 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin : JSON.stringify( { play_slots: playSlots, - brief: "Boss直聘落档自动生成", + brief: "对话落盘落档自动生成", }, null, 2, @@ -339,14 +339,14 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin } row = parsed as Record; } catch { - row = { play_slots: playSlots, brief: "Boss直聘落档自动生成" }; + row = { play_slots: playSlots, brief: "对话落盘落档自动生成" }; } row.play_slots = playSlots; if (playSlots.narrator) { row.brief = typeof row.brief === "string" && String(row.brief).includes("转述") ? row.brief - : "Boss直聘:主世界出内容,叙事转述出文风"; + : "对话落盘:主世界出内容,叙事转述出文风"; } const withSlots = JSON.stringify(row, null, 2); const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts index 159a948..f2cd6c5 100644 --- a/src/dictate/prompt.ts +++ b/src/dictate/prompt.ts @@ -1,5 +1,5 @@ /** - * Boss 直聘式 · 创作进料专用系统提示 + * 对话落盘 · 创作进料专用系统提示 * * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 * 仅用于创作层;开玩后由游玩管线接棒。 @@ -28,57 +28,92 @@ ${opts.moduleGuide.trim()} ` : ""; - return `你是 Boss 直聘式创作助手:把用户原话整理成可落盘规格。作用对象是本局创作上下文(对话可清空,产物保留)。 + return `你是对话落盘创作助手:把用户原话整理成可落盘规格,并用可见回复让用户确认、补洞、选下一刀。作用对象是本局创作上下文(对话可清空,产物保留)。 -**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;不要假装在跑游玩回合。 +**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。 ## 落盘节奏(先读) -1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物。 -2. 每轮通常只推进一刀(一个能力或一组紧密相关的真值);用户一次说很多也不要批量抢跑下游。 -3. 配方顺序是骨架;具体能不能落,以各能力「何时用 / 何时不用」为准。 +1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物,改为短确认或问挡住的 1 点。 +2. **同轮可多次 insert / declare_***(一步并行或多个 tool 轮次都行):用户本轮材料已够、且多个能力的「何时用」都成立时,把这些都落掉;每个成功的 insert 各回一份 \`reply_module\`,可见回复按模块分段附带。 +3. **不抢跑未齐条件的下游**(这才是「别一口气铺满」的意思):条件未成立的能力本轮不落,写进「建议下一刀」等用户补料或上游齐了再落。配方顺序是骨架;能不能落以各能力「何时用 / 何时不用」为准。 ## 落盘(仅允许 toolcall) -禁止把设定/变量/映射全文只写在聊天里冒充已落盘。 +设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。 -### insert — 不变型固定产物 +### insert — 写入/覆盖固定产物 - position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag) - content:正文;order 可选(越小越靠前;越常改越大) -- 勿用 insert 写变量目录或映射(用下面两个工具) +- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map +- **〔可反复〕须拆分**:生成规则 / 具体实例等每条增殖用 \`基名#唯一id\`(如 \`设计.生成规则#ex-girlfriend-card\`、\`设计.具体实例#lei-ying\`)。id 用 rule_id / batch_id / 姓名 slug。禁止反复 insert 基名覆盖旧条;改某一条才 insert 同一完整 tag。 + +### delete — 删除固定产物 +- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除 +- 改内容用 insert,不要 delete 再重写除非用户要丢掉整块 +- 删单个变量字段 → undeclare_variable;删单条映射 → remove_map ### declare_variable — 跨轮真值 - key / type / initial / user_visible(用户是否可见) - 只立必须跨轮记住的状态;派生文案不要立成可写变量 -- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不要抢 declare +- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不 declare +- 移除字段 → undeclare_variable ### declare_map — 真值→投影上下文 - id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[] - bands:\`{min,max,content}\` 区间(min 含 max 不含),或 \`{value,content}\`,或 \`{when:"default",content}\` - 槽位固定、内容随真值换档;旧档离开上下文 +- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写) -**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C(须是条件已接近成立的下一刀)、提问。不要复述 tool 全文。 +## 对用户可见回复 +tool 跑完后写给用户看的 markdown。作用:确认本轮落了什么、点出薄弱处、用可选追问引导补洞或下一刀。 -**分清:** +执行顺序: +1. 先完成本轮需要的 insert / declare_*。 +2. 收集各次 insert 成功返回的 \`reply_module\`(若有;每个模块一份独立 markdown)。 +3. 按下列规格写自然语言(粘贴产物全文或 tool JSON 都算失败): + +**有 1 个 reply_module** +- 用二级标题 \`## 模块 · {名称}\`(名称与该 reply_module 标题一致) +- 段内按该模块的「写入用户回复时做什么」写满:确认要点 → 口头薄弱点 → 追问 1~2 点(每点含建议选项或短场景) + +**有 N 个 reply_module(N≥2)** +- 同轮多次 insert,或一步并行多个,都会各回一份 \`reply_module\` +- 每个模块各写一块,标题互不相同的 \`## 模块 · {名称}\`;块与块之间空一行 +- 每一块只服务该模块;全部模块写完后,可另起一段写「建议下一刀」(仅 1 条、且条件已接近成立) + +**没有 reply_module** +- 用 2~4 句确认本轮写入的 tag 要点,并建议条件已近的下一刀;若落盘被挡住,只问挡住的 1 点 + +## 分清落盘目标 - 正文组成(格式)→ insert \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表) -- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐不硬塞) -- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.叙事指南与故事推进\`(大纲扩写) -- 好感分档性格等 → declare_variable + declare_map,不要 insert 一整张表进设定散文 +- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐则先补上游,不硬塞开场) +- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\` +- 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文 +- 代入名片(名字/身份/金手指)→ insert \`设计.主角设定\`(对照「主角设定」何时用;名字写成具体称呼,不用 @玩家 / 用户 / 主角) ${recipeBlock}${moduleBlock} ## 按用户本轮意图择一为主(仍须过「何时用」) +落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。 + ### A · 钉体验 / 设定 -优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。回复:确认本刀 + 建议条件已近的下一刀。 +优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。 ### B · 钉正文格式 「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。 ### C · 生成开场白 -收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(Markdown;岔路用 \`## 岔路\`)。 -**content 规格:** 正文必须含字面 \`@玩家\`(至少一处)作用户角色名位;禁止写死姓名;禁止全文只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。 +收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(岔路用 \`## 岔路\`)。 +**形状:** meta + 正文。有代入名片时,开头 YAML \`用户角色.名字\` / \`用户角色.简介\`,再 \`---\`,下面才是开场正文。不同开场可有不同用户角色。 +**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。 +**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。 +进玩时若有用户角色,程序提示是否切换到这个预设。 ### D · 钉变量与映射 -仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。勿因配方名叫数据化就抢跑 D。 +仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。 ### E · 文本生成器(创作收料 → 开玩挂载) -必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.叙事指南与故事推进\`(用户输入用法:大纲扩写;禁止扮演停笔)。缺任一就先补产物,不要说可以开玩。禁止在创作期写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。 +必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。 + +### F · 快穿短局 +仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介则写进开场白的用户角色 meta(不单独 insert 主角设定)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。 ## 常用序感 - 需求/美学等不变设定:靠前(负) diff --git a/src/dictate/repeatable-tags.ts b/src/dictate/repeatable-tags.ts new file mode 100644 index 0000000..1ef39f9 --- /dev/null +++ b/src/dictate/repeatable-tags.ts @@ -0,0 +1,217 @@ +/** + * 对话落盘 · 可增殖产物 tag 拆分 + * + * catalog 标 repeatable 的能力(生成规则 / 具体实例)共用同一 artifact 基名。 + * 对话里若反复 insert 基 tag,后写会整份覆盖先写。 + * 约定:基名#槽位(如 设计.具体实例#lei-ying),每条增殖各占一 tag。 + */ + +import { tryParseJsonDoc } from "../parse/json-doc.js"; + +export const REPEATABLE_TAG_SEP = "#"; + +/** 去掉 #槽位,得到能力 artifact 基名 */ +export function dictateProductFamily(tag: string): string { + const t = tag.trim(); + if (!t) return ""; + const i = t.indexOf(REPEATABLE_TAG_SEP); + return i >= 0 ? t.slice(0, i).trim() : t; +} + +/** 显式槽位;无 # 则空 */ +export function dictateProductSlot(tag: string): string { + const t = tag.trim(); + const i = t.indexOf(REPEATABLE_TAG_SEP); + if (i < 0) return ""; + return sanitizeRepeatableSlot(t.slice(i + 1)); +} + +export function isDictateProductFamily(tag: string, family: string): boolean { + return dictateProductFamily(tag) === family.trim(); +} + +/** 槽位:英文 kebab / 短中文均可;去掉危险字符 */ +export function sanitizeRepeatableSlot(raw: string): string { + return String(raw ?? "") + .trim() + .replace(/[\n\r\0#/\\]/g, "") + .replace(/\s+/g, "-") + .slice(0, 64); +} + +export function buildRepeatableProductTag( + family: string, + slot: string, +): string { + const base = family.trim(); + const s = sanitizeRepeatableSlot(slot); + return s ? `${base}${REPEATABLE_TAG_SEP}${s}` : base; +} + +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t || undefined; +} + +function bodyOfFragment(content: string): Record | null { + const parsed = tryParseJsonDoc(content); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const body = (parsed as Record).正文; + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + return body as Record; +} + +/** + * 从正文抽出建议槽位:生成规则 → rule_id;具体实例 → batch_id / 首条姓名。 + */ +export function extractRepeatableSlotFromContent( + family: string, + content: string, +): string | undefined { + const body = bodyOfFragment(content); + if (!body) return undefined; + const base = family.trim(); + + if (base === "设计.生成规则" || /生成规则$/.test(base)) { + const top = + asString(body.rule_id) ?? asString(body.id) ?? asString(body.规则id); + if (top) return sanitizeRepeatableSlot(top) || undefined; + const rules = body.rules; + if (Array.isArray(rules)) { + for (const row of rules) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const r = row as Record; + const id = + asString(r.rule_id) ?? asString(r.id) ?? asString(r.规则id); + if (id) return sanitizeRepeatableSlot(id) || undefined; + } + } + const target = asString(body.target) ?? asString(body.对象) ?? asString(body.对象族); + if (target) return sanitizeRepeatableSlot(target) || undefined; + return undefined; + } + + if (base === "设计.具体实例" || /具体实例$/.test(base)) { + const batch = + asString(body.batch_id) ?? + asString(body.批次id) ?? + asString(body.id); + if (batch) return sanitizeRepeatableSlot(batch) || undefined; + const records = body.records; + if (Array.isArray(records)) { + for (const row of records) { + if (!row || typeof row !== "object" || Array.isArray(row)) continue; + const r = row as Record; + const name = + asString(r.姓名) ?? + asString(r.name) ?? + asString(r.名称) ?? + asString(r.id); + if (name) return sanitizeRepeatableSlot(name) || undefined; + } + } + const ruleId = asString(body.rule_id); + if (ruleId) { + return sanitizeRepeatableSlot(`${ruleId}-batch`) || undefined; + } + return undefined; + } + + return undefined; +} + +export type ResolveRepeatableInsertResult = + | { tag: string; remapped?: boolean; note?: string } + | { error: string }; + +/** + * 可增殖 insert:有槽位则落到 基名#槽位;无槽位且基名已有产物 → 拒绝覆盖。 + */ +export function resolveRepeatableInsertTag(params: { + tag: string; + content: string; + existingTags: readonly string[]; + repeatableFamilies: ReadonlySet; +}): ResolveRepeatableInsertResult { + const raw = params.tag.trim(); + if (!raw) return { error: "position 为空" }; + + const family = dictateProductFamily(raw); + if (!params.repeatableFamilies.has(family)) { + return { tag: raw }; + } + + const explicit = dictateProductSlot(raw); + const extracted = extractRepeatableSlotFromContent(family, params.content); + const slot = explicit || extracted || ""; + + const siblings = params.existingTags + .map((t) => t.trim()) + .filter((t) => t && dictateProductFamily(t) === family); + + if (!slot) { + if (siblings.length > 0 && raw === family) { + return { + error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`, + }; + } + return { tag: family }; + } + + const resolved = buildRepeatableProductTag(family, slot); + if (raw === family || raw !== resolved) { + return { + tag: resolved, + remapped: raw !== resolved, + note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined, + }; + } + return { tag: resolved }; +} + +/** 收集同族全部正文(供旁观抽样等仍按「一份生成规则」读的逻辑) */ +export function collectFamilyContents( + entries: ReadonlyArray<{ tag: string; content: string }>, + family: string, +): string[] { + const base = family.trim(); + return entries + .filter((e) => isDictateProductFamily(e.tag, base) && e.content.trim()) + .map((e) => e.content); +} + +/** + * 把多份生成规则 context-fragment 合成一份(合并 rules[]),便于 findRuleBody。 + */ +export function mergeGenerationRulesArtifacts( + rawList: readonly string[], +): string | undefined { + const rules: unknown[] = []; + let shell: Record | null = null; + for (const raw of rawList) { + const parsed = tryParseJsonDoc(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + const doc = parsed as Record; + if (!shell) shell = { ...doc }; + const body = doc.正文; + if (!body || typeof body !== "object" || Array.isArray(body)) continue; + const rows = (body as Record).rules; + if (!Array.isArray(rows)) continue; + for (const row of rows) rules.push(row); + } + if (!shell || rules.length === 0) { + // 无法 JSON 合并时:按序拼接,抽样侧仍可能从单份命中 + const nonempty = rawList.map((s) => s.trim()).filter(Boolean); + return nonempty.length ? nonempty.join("\n\n---\n\n") : undefined; + } + const body = + shell.正文 && typeof shell.正文 === "object" && !Array.isArray(shell.正文) + ? { ...(shell.正文 as Record), rules } + : { rules }; + return JSON.stringify({ ...shell, 正文: body }, null, 2); +} diff --git a/src/dictate/tools.ts b/src/dictate/tools.ts index 937fa55..d6332fd 100644 --- a/src/dictate/tools.ts +++ b/src/dictate/tools.ts @@ -1,7 +1,7 @@ import type { ToolDefinition } from "../llm/client.js"; /** - * Boss 直聘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 + * 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 */ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ { @@ -9,19 +9,19 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ function: { name: "insert", description: - "写入不变型固定产物(设定/格式/开场等)。确认与建议只写在普通回复。用 order 标相对先后。", + "写入或覆盖固定产物。同 position 再调用即整份改写。可增殖(生成规则/具体实例)须用 基名#唯一id 拆分,禁止反复写基名覆盖。成功时可能返回 reply_module:末尾可见回复须附带该模块;同轮多个则每个模块各写一块 ## 模块 · 名称。确认与追问写在普通回复,不写进本工具。order 标相对先后。", parameters: { type: "object", properties: { position: { type: "string", description: - "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白。勿用本工具写变量目录/映射。", + "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id。勿用本工具写变量目录/映射。", }, content: { type: "string", description: - "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场 Markdown,必须含字面 @玩家(用户角色名位,禁止写死姓名)", + "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=meta + 正文(有正文组成时正文为 present.v1 JSON;否则 Markdown),必须含字面 @玩家(用户角色名位,禁止写死姓名)", }, order: { type: "number", @@ -112,6 +112,68 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ }, }, }, + { + type: "function", + function: { + name: "delete", + description: + "删除一个已落盘的固定产物 tag(整份移除)。改内容请用 insert 覆盖;删变量字段用 undeclare_variable;删映射条目用 remove_map。勿用本工具写空 content 假装删除。", + parameters: { + type: "object", + properties: { + position: { + type: "string", + description: + "要删的产物 tag,须以「用户.」或「设计.」开头。例:设计.开场白。删整份变量目录/映射也可(设计.变量目录 / 设计.变量映射)。", + }, + reason: { + type: "string", + description: "为何删除(一句话,可选)", + }, + }, + required: ["position"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "undeclare_variable", + description: + "从设计.变量目录移除一个真值字段,并同步从变量.当前去掉该格。改初值/可见性请用 declare_variable。", + parameters: { + type: "object", + properties: { + key: { + type: "string", + description: "要移除的字段名", + }, + }, + required: ["key"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "remove_map", + description: + "从设计.变量映射按 id 移除一条映射;若无其它映射共用其投影 tag,则一并删除该投影 tag。", + parameters: { + type: "object", + properties: { + id: { + type: "string", + description: "映射 id(与 declare_map 时一致)", + }, + }, + required: ["id"], + additionalProperties: false, + }, + }, + }, { type: "function", function: { diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts index ae82c03..efddff4 100644 --- a/src/dictate/turn.ts +++ b/src/dictate/turn.ts @@ -12,11 +12,14 @@ import { type DictateChatTurn, type DictateProduct, } from "./types.js"; +import { DICTATE_MULTI_MODULE_REPLY_HINT } from "./insert-feedback.js"; +import { resolveRepeatableInsertTag } from "./repeatable-tags.js"; import { VARIABLE_CATALOG_TAG, parseVariableCatalog, serializeVariableCatalog, upsertVariableField, + removeVariableField, type UpsertVariableInput, } from "../skills/variable-catalog.js"; import { @@ -24,31 +27,46 @@ import { parseValueMapDoc, serializeValueMapDoc, upsertValueMapEntry, + removeValueMapEntry, type UpsertMapInput, } from "../skills/value-map.js"; +import { + parseTableDoc, + stringifyTableDoc, +} from "../blackboard/table-cells.js"; export type DictateTurnHandlers = { listProducts: () => DictateProduct[]; /** order 省略时由实现沿用原序或默认 */ writeProduct: (tag: string, content: string, order?: number) => void; + /** 删除固定产物 tag;返回是否曾存在 */ + deleteProduct: (tag: string) => boolean; /** 读/写变量目录与映射(程序校验后的 JSON) */ readTag: (tag: string) => string | undefined; writeTag: (tag: string, content: string) => void; + /** 删除任意黑板 tag(投影 / 变量.当前 等) */ + deleteTag: (tag: string) => boolean; clearDialogue: () => void; onToolCall?: (name: string, detail: string) => void; onThinkingDelta?: (delta: string) => void; onThinkingDone?: (text: string) => void; + /** + * insert 成功后:按 tag 反查能力,返回独立「回复模块」markdown。 + * 程序写入 tool result 的 reply_module;同轮多个须在末尾回复中全部附带。 + */ + lookupInsertFeedback?: (tag: string) => string | undefined; }; export type DictateTurnResult = { reply: string; iterations: number; wroteTags: string[]; + deletedTags: string[]; clearedDialogue: boolean; }; /** - * 跑一轮 Boss 直聘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。 + * 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。 */ export async function runDictateTurn(params: { llm: LlmProvider; @@ -59,12 +77,18 @@ export async function runDictateTurn(params: { recipeBrief?: string; /** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */ moduleGuide?: string; + /** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */ + repeatableFamilies?: ReadonlySet | readonly string[]; driver?: AgentDriver; }): Promise { const driver = params.driver ?? createLocalLlmDriver(params.llm); const wroteTags: string[] = []; + const deletedTags: string[] = []; let clearedDialogue = false; let dialogue = [...params.dialogue]; + const repeatableFamilies = new Set( + [...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean), + ); const systemPrompt = params.systemPrompt ?? @@ -85,14 +109,15 @@ export async function runDictateTurn(params: { messages: userBlock, tools: DICTATE_TOOL_DEFINITIONS, caller: "dictate_agent", - label: "Boss直聘", + label: "对话落盘", onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDone: params.handlers.onThinkingDone, handleStep: (calls) => { + const moduleNamesThisStep: string[] = []; const results = calls.map((call) => { const args = safeParseArgs(call.arguments); if (call.name === "insert" || call.name === "write_product") { - const tag = String(args.position ?? args.tag ?? "").trim(); + let tag = String(args.position ?? args.tag ?? "").trim(); const content = String(args.content ?? ""); if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) { const err = `请用 declare_variable / declare_map,不要 insert ${tag}`; @@ -109,18 +134,47 @@ export async function runDictateTurn(params: { params.handlers.onToolCall?.(call.name, err); return { callId: call.id, content: JSON.stringify({ error: err }) }; } + if (repeatableFamilies.size) { + const resolved = resolveRepeatableInsertTag({ + tag, + content, + existingTags: params.handlers.listProducts().map((p) => p.tag), + repeatableFamilies, + }); + if ("error" in resolved) { + params.handlers.onToolCall?.(call.name, resolved.error); + return { + callId: call.id, + content: JSON.stringify({ error: resolved.error }), + }; + } + tag = resolved.tag; + } const order = parseDictateOrder(args.order); params.handlers.writeProduct(tag, content, order); wroteTags.push(tag); const detail = order === undefined ? tag : `${tag} order=${order}`; params.handlers.onToolCall?.(call.name, detail); + const replyModule = + params.handlers.lookupInsertFeedback?.(tag)?.trim() || undefined; + if (replyModule) { + const title = + replyModule.match(/^#\s*模块\s*·\s*(.+)$/m)?.[1]?.trim() || tag; + moduleNamesThisStep.push(title); + } return { callId: call.id, content: JSON.stringify({ ok: true, position: tag, ...(order !== undefined ? { order } : {}), + ...(replyModule + ? { + reply_module: replyModule, + reply_hint: DICTATE_MULTI_MODULE_REPLY_HINT, + } + : {}), }), }; } @@ -185,6 +239,108 @@ export async function runDictateTurn(params: { }), }; } + if (call.name === "delete") { + const tag = String(args.position ?? args.tag ?? "").trim(); + const reason = String(args.reason ?? "").trim(); + if (!isAllowedProductTag(tag)) { + const err = `拒绝删除:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } + const existed = params.handlers.deleteProduct(tag); + if (!existed) { + const err = `无产物 ${tag}`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } + deletedTags.push(tag); + const detail = reason ? `${tag}(${reason})` : tag; + params.handlers.onToolCall?.(call.name, detail); + return { + callId: call.id, + content: JSON.stringify({ ok: true, deleted: tag }), + }; + } + if (call.name === "undeclare_variable") { + const key = String(args.key ?? "").trim(); + const current = parseVariableCatalog( + params.handlers.readTag(VARIABLE_CATALOG_TAG), + ); + const { doc, error } = removeVariableField(current, key); + if (error) { + params.handlers.onToolCall?.(call.name, error); + return { callId: call.id, content: JSON.stringify({ error }) }; + } + if (doc.fields.length === 0) { + params.handlers.deleteTag(VARIABLE_CATALOG_TAG); + } else { + params.handlers.writeTag( + VARIABLE_CATALOG_TAG, + serializeVariableCatalog(doc), + ); + } + // 同步从 变量.当前 / 运行.初始变量 去掉该格 + for (const tableTag of ["变量.当前", "运行.初始变量"] as const) { + const raw = params.handlers.readTag(tableTag); + const table = parseTableDoc(raw); + if (!table?.rows.some((r) => r.key === key)) continue; + const next = { + rows: table.rows.filter((r) => r.key !== key), + }; + if (next.rows.length === 0) { + params.handlers.deleteTag(tableTag); + } else { + params.handlers.writeTag(tableTag, stringifyTableDoc(next)); + } + } + deletedTags.push(`${VARIABLE_CATALOG_TAG}#${key}`); + params.handlers.onToolCall?.(call.name, key); + return { + callId: call.id, + content: JSON.stringify({ + ok: true, + key, + fields: doc.fields.length, + }), + }; + } + if (call.name === "remove_map") { + const id = String(args.id ?? "").trim(); + const current = parseValueMapDoc( + params.handlers.readTag(VALUE_MAP_TAG), + ); + const { doc, error, removed } = removeValueMapEntry(current, id); + if (error || !removed) { + const err = error ?? `映射无 id ${id}`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } + if (doc.maps.length === 0) { + params.handlers.deleteTag(VALUE_MAP_TAG); + } else { + params.handlers.writeTag(VALUE_MAP_TAG, serializeValueMapDoc(doc)); + } + const stillUsed = doc.maps.some( + (m) => m.target_tag === removed.target_tag, + ); + if (!stillUsed) { + params.handlers.deleteTag(removed.target_tag); + } + deletedTags.push(`${VALUE_MAP_TAG}#${id}`); + params.handlers.onToolCall?.( + call.name, + `${id} → ${removed.target_tag}`, + ); + return { + callId: call.id, + content: JSON.stringify({ + ok: true, + id, + cleared_target: stillUsed ? null : removed.target_tag, + maps: doc.maps.length, + }), + }; + } if (call.name === "clear_dialogue") { const reason = String(args.reason ?? "").trim() || "上下文过长"; params.handlers.clearDialogue(); @@ -204,6 +360,22 @@ export async function runDictateTurn(params: { params.handlers.onToolCall?.(call.name, err); return { callId: call.id, content: JSON.stringify({ error: err }) }; }); + + if (moduleNamesThisStep.length > 1) { + const roster = moduleNamesThisStep.join("、"); + for (const r of results) { + try { + const parsed = JSON.parse(r.content) as Record; + if (typeof parsed.reply_module === "string") { + parsed.reply_hint = `${DICTATE_MULTI_MODULE_REPLY_HINT} 本步已带回模块:${roster}。`; + r.content = JSON.stringify(parsed); + } + } catch { + /* ignore */ + } + } + } + return { kind: "continue" as const, results }; }, }); @@ -213,8 +385,15 @@ export async function runDictateTurn(params: { reply = run.stop.content.trim(); } if (!reply) { - if (wroteTags.length) { - reply = `已写入:${[...new Set(wroteTags)].join("、")}。还有要补充的吗?`; + if (wroteTags.length || deletedTags.length) { + const parts: string[] = []; + if (wroteTags.length) { + parts.push(`已写入:${[...new Set(wroteTags)].join("、")}`); + } + if (deletedTags.length) { + parts.push(`已删除:${[...new Set(deletedTags)].join("、")}`); + } + reply = `${parts.join(";")}。还有要补充的吗?`; } else if (clearedDialogue) { reply = "对话已清空,产物都还在。可以继续说。"; } else { @@ -226,6 +405,7 @@ export async function runDictateTurn(params: { reply, iterations: run.iterations, wroteTags: [...new Set(wroteTags)], + deletedTags: [...new Set(deletedTags)], clearedDialogue, }; } diff --git a/src/dictate/types.ts b/src/dictate/types.ts index 0ef93d7..f1592e7 100644 --- a/src/dictate/types.ts +++ b/src/dictate/types.ts @@ -33,17 +33,22 @@ export function isAllowedProductTag(tag: string): boolean { /** * 未显式给 order 时的默认相对序。 * 越稳定/重要越靠前(更负);越常改越靠后(更正)。 + * 可增殖拆分 tag(基名#槽)按基名归类。 */ export function defaultDictateOrder(tag: string): number { const t = tag.trim(); - if (t === "用户.需求") return -40; - if (t === "设计.变量目录" || t === "设计.变量映射") return -25; - if (t === "设计.模仿范例" || t === "设计.模仿要点") return -28; - if (/美学|纲领|禁忌|示例|模仿/.test(t)) return -30; - if (/文风|叙事指南|篇幅|结构/.test(t)) return -20; - if (/短文集|短文\./.test(t)) return 10; - if (/正文组成|回复格式/.test(t)) return 0; - if (/开场白/.test(t)) return 20; + const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t; + if (family === "用户.需求" || t === "用户.需求") return -40; + if (family === "设计.变量目录" || family === "设计.变量映射") return -25; + if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28; + if (/美学|纲领|禁忌|示例|模仿/.test(family)) return -30; + if (/主角设定/.test(family)) return -26; + if (/文风|叙事指南|故事推进|篇幅|结构/.test(family)) return -20; + if (/生成规则/.test(family)) return -6; + if (/具体实例/.test(family)) return -4; + if (/短文集|短文\./.test(family)) return 10; + if (/正文组成|回复格式/.test(family)) return 0; + if (/开场白/.test(family)) return 20; return 0; } diff --git a/src/llm/client.ts b/src/llm/client.ts index 6934924..0631801 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -80,6 +80,8 @@ export type CompleteOptions = { generation?: GenerationParameters; /** 统计用途,如 main_agent / worker:write-rules */ caller?: string; + /** 日志短名,如「对话落盘 第2轮」;缺则用 caller */ + label?: string; /** 取消进行中的 LLM 请求(停止并重试) */ signal?: AbortSignal; }; diff --git a/src/llm/request-log.ts b/src/llm/request-log.ts new file mode 100644 index 0000000..39526cd --- /dev/null +++ b/src/llm/request-log.ts @@ -0,0 +1,191 @@ +/** + * 模型请求日志:发送一行、完成/中断/失败一行。 + * 只写任务骨架(轮次、待消化什么、可调工具、上下文体积),不写正文。 + */ +import type { + ChatMessage, + CompleteResult, + CompleteWithToolsResult, + ToolDefinition, +} from "./client.js"; +import { isAbortError } from "./run-abort.js"; +import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js"; + +export type LlmRequestLogInput = { + kind: string; + caller?: string; + label?: string; + messages: ChatMessage[]; + tools?: ToolDefinition[]; +}; + +function requestTitle(caller: string | undefined, label: string | undefined): string { + const task = label?.trim(); + const who = labelCaller(caller); + if (task && (task === who || task.startsWith(`${who} `) || task.startsWith(`${who}·`))) { + return task; + } + if (task) return `${who} · ${task}`; + return who; +} + +function countRoles(messages: ChatMessage[]): string { + const n = { system: 0, user: 0, assistant: 0, tool: 0 }; + for (const m of messages) { + if (m.role === "system") n.system += 1; + else if (m.role === "user") n.user += 1; + else if (m.role === "assistant") n.assistant += 1; + else if (m.role === "tool") n.tool += 1; + } + const bits = [ + n.system ? `系统${n.system}` : "", + n.user ? `用户${n.user}` : "", + n.assistant ? `助手${n.assistant}` : "", + n.tool ? `工具${n.tool}` : "", + ].filter(Boolean); + return bits.length ? `条=${bits.join("+")}` : "条=0"; +} + +function messageChars(m: ChatMessage): number { + let n = typeof m.content === "string" ? m.content.length : 0; + if (m.role === "assistant" && m.tool_calls) { + for (const tc of m.tool_calls) { + n += (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0); + } + } + return n; +} + +function formatContextSize(messages: ChatMessage[]): string { + const n = messages.reduce((sum, m) => sum + messageChars(m), 0); + if (n >= 10_000) { + const wan = n / 10_000; + const text = wan >= 10 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, ""); + return `上下文≈${text}万字`; + } + return `上下文=${n}字`; +} + +function nearestAssistantToolNames(messages: ChatMessage[]): string[] { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === "assistant" && m.tool_calls?.length) { + return m.tool_calls.map((tc) => tc.function.name); + } + } + return []; +} + +/** 这次请求在等什么:用户话、工具结果、还是空上下文。不含正文。 */ +export function describeRequestAwaiting(messages: ChatMessage[]): string { + if (!messages.length) return "空上下文"; + const last = messages[messages.length - 1]; + if (last.role === "user") return "待答用户"; + if (last.role === "tool") { + const names = nearestAssistantToolNames(messages); + return names.length + ? `待消化 ${names.map((n) => labelTool(n)).join("、")}` + : "待消化工具"; + } + if (last.role === "assistant") { + const names = + last.tool_calls?.map((tc) => tc.function.name) ?? []; + return names.length + ? `续轮 ${names.map((n) => labelTool(n)).join("、")}` + : "待续助手"; + } + if (last.role === "system") return "仅系统"; + return last.role; +} + +function formatAvailableTools(tools: ToolDefinition[] | undefined): string { + if (!tools?.length) return ""; + const names = tools.map((t) => labelTool(t.function.name)); + if (names.length <= 6) return `可调 ${names.join("、")}`; + return `可调 ${names.slice(0, 5).join("、")} 等${names.length}个`; +} + +export function formatLlmSendLine(input: LlmRequestLogInput): string { + const title = requestTitle(input.caller, input.label); + const mode = labelLlmMode(input.kind); + const bits = [ + `发送 ${title} · ${mode}`, + describeRequestAwaiting(input.messages), + countRoles(input.messages), + formatAvailableTools(input.tools), + formatContextSize(input.messages), + ]; + return bits.filter(Boolean).join(" "); +} + +function formatUsage(usage: CompleteResult["usage"]): string { + if (!usage) return ""; + const cache = + usage.cachedTokens != null && usage.cachedTokens > 0 + ? ` 缓存=${usage.cachedTokens}` + : ""; + return `用量=${usage.promptTokens}+${usage.completionTokens}${cache}`; +} + +export function formatLlmFinishLine( + input: LlmRequestLogInput, + elapsedMs: number, + result: CompleteResult | CompleteWithToolsResult, +): string { + const title = requestTitle(input.caller, input.label); + const mode = labelLlmMode(input.kind); + const toolCalls = + "toolCalls" in result + ? result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无" + : undefined; + const bits = [ + `完成 ${title} · ${mode}`, + `${elapsedMs}毫秒`, + result.model ? `模型=${result.model}` : "", + `思维链=${result.reasoning?.trim().length ?? 0}字`, + `正文=${result.content?.length ?? 0}字`, + toolCalls != null ? `调用=${toolCalls}` : "", + formatUsage(result.usage), + ]; + return bits.filter(Boolean).join(" "); +} + +export function formatLlmAbortLine( + input: Pick, + elapsedMs: number, +): string { + const title = requestTitle(input.caller, input.label); + const mode = labelLlmMode(input.kind); + return `中断 ${title} · ${mode} ${elapsedMs}毫秒`; +} + +export function formatLlmFailLine( + input: Pick, + elapsedMs: number, + err: unknown, +): string { + const title = requestTitle(input.caller, input.label); + const mode = labelLlmMode(input.kind); + const detail = err instanceof Error ? err.message : String(err); + return `失败 ${title} · ${mode} ${elapsedMs}毫秒 ${detail.slice(0, 160)}`; +} + +export async function loggedLlmRequest< + T extends CompleteResult | CompleteWithToolsResult, +>(input: LlmRequestLogInput, run: () => Promise): Promise { + debugLog("llm", formatLlmSendLine(input)); + const t0 = Date.now(); + try { + const result = await run(); + debugLog("llm", formatLlmFinishLine(input, Date.now() - t0, result)); + return result; + } catch (err) { + const elapsed = Date.now() - t0; + if (isAbortError(err)) { + debugLog("llm", formatLlmAbortLine(input, elapsed)); + } else { + debugLog("llm", formatLlmFailLine(input, elapsed, err)); + } + throw err; + } +} diff --git a/src/llm/token-tracker.ts b/src/llm/token-tracker.ts index 1831c40..26bcca1 100644 --- a/src/llm/token-tracker.ts +++ b/src/llm/token-tracker.ts @@ -16,7 +16,7 @@ import { buildContextTrace, type LlmContextTrace, } from "../types/context-trace.js"; -import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js"; +import { loggedLlmRequest } from "./request-log.js"; export type LlmTrackingContext = { sessionId?: string; @@ -73,54 +73,6 @@ function recordUsage( ctx.pendingUsage = toMessageTokenUsage(record); } -function summarizeComplete(result: CompleteResult): string { - const bits = [ - result.model ? `模型=${result.model}` : "", - `思维链=${result.reasoning?.trim().length ?? 0}字`, - `正文=${result.content?.length ?? 0}字`, - ]; - if (result.usage) { - bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`); - } - return bits.filter(Boolean).join(" "); -} - -function summarizeTools(result: CompleteWithToolsResult): string { - const tools = - result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无"; - const bits = [ - result.model ? `模型=${result.model}` : "", - `思维链=${result.reasoning?.trim().length ?? 0}字`, - `正文=${result.content?.length ?? 0}字`, - `工具=${tools}`, - ]; - if (result.usage) { - bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`); - } - return bits.filter(Boolean).join(" "); -} - -async function loggedLlm( - kind: string, - caller: string | undefined, - run: () => Promise, - summarize: (result: T) => string, -): Promise { - const who = labelCaller(caller); - const mode = labelLlmMode(kind); - const t0 = Date.now(); - debugLog("llm", `开始 ${who} · ${mode}`); - try { - const result = await run(); - debugLog("llm", `结束 ${who} · ${mode} ${Date.now() - t0}毫秒 ${summarize(result)}`); - return result; - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - debugLog("llm", `失败 ${who} · ${mode} ${Date.now() - t0}毫秒 ${detail.slice(0, 160)}`); - throw err; - } -} - export class TokenTrackingProvider implements LlmProvider { constructor( private readonly inner: LlmProvider, @@ -131,9 +83,13 @@ export class TokenTrackingProvider implements LlmProvider { messages: Parameters[0], options?: CompleteOptions, ): Promise { - return loggedLlm( - "complete", - options?.caller, + return loggedLlmRequest( + { + kind: "complete", + caller: options?.caller, + label: options?.label, + messages, + }, async () => { const result = await this.inner.complete(messages, options); const ctx = this.getContext(); @@ -150,7 +106,6 @@ export class TokenTrackingProvider implements LlmProvider { } return result; }, - summarizeComplete, ); } @@ -166,9 +121,13 @@ export class TokenTrackingProvider implements LlmProvider { if (result.content) callbacks.onContentDelta?.(result.content); return result; } - return loggedLlm( - "stream", - options?.caller, + return loggedLlmRequest( + { + kind: "stream", + caller: options?.caller, + label: options?.label, + messages, + }, async () => { let reasoningBuf = ""; const result = await inner.completeStream!(messages, options, { @@ -194,7 +153,6 @@ export class TokenTrackingProvider implements LlmProvider { } return result; }, - summarizeComplete, ); } @@ -202,9 +160,14 @@ export class TokenTrackingProvider implements LlmProvider { messages: Parameters[0], options: CompleteWithToolsOptions, ): Promise { - return loggedLlm( - "tools", - options.caller, + return loggedLlmRequest( + { + kind: "tools", + caller: options.caller, + label: options.label, + messages, + tools: options.tools, + }, async () => { const result = await this.inner.completeWithTools(messages, options); const ctx = this.getContext(); @@ -221,7 +184,6 @@ export class TokenTrackingProvider implements LlmProvider { } return result; }, - summarizeTools, ); } @@ -234,9 +196,14 @@ export class TokenTrackingProvider implements LlmProvider { if (!inner.completeWithToolsStream) { return this.completeWithTools(messages, options); } - return loggedLlm( - "tools-stream", - options.caller, + return loggedLlmRequest( + { + kind: "tools-stream", + caller: options.caller, + label: options.label, + messages, + tools: options.tools, + }, async () => { let reasoningBuf = ""; const result = await inner.completeWithToolsStream!(messages, options, { @@ -262,7 +229,6 @@ export class TokenTrackingProvider implements LlmProvider { } return result; }, - summarizeTools, ); } } diff --git a/src/log.ts b/src/log.ts index 2288e1c..07c031f 100644 --- a/src/log.ts +++ b/src/log.ts @@ -97,6 +97,17 @@ const TOOL_LABEL: Record = { ask_user: "问用户", review_blackboard: "审黑板", finish: "结束", + insert: "写入产物", + write_product: "写入产物", + delete: "删除产物", + declare_variable: "声明变量", + undeclare_variable: "移除变量", + declare_map: "声明映射", + remove_map: "移除映射", + clear_dialogue: "清空对话", + chance: "机遇裁定", + submit_present_packet: "提交呈现", + submit_worker_result: "提交结果", }; const ACTION_LABEL: Record = { @@ -202,6 +213,11 @@ export function labelCaller(caller: string | undefined): string { const who = caller?.trim() || "未知"; if (who === "main_agent" || who.startsWith("main_agent:")) return "总管"; if (who === "intake_extract") return "需求抽取"; + if (who === "dictate_agent") return "对话落盘"; + if (who === "preset-probe") return "预设探测"; + if (who.startsWith("model-compare:")) { + return `模型对比 ${who.slice("model-compare:".length)}`; + } if (who.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`; if (who === "unknown") return "未知"; return who; diff --git a/src/persona/protagonist-bind.ts b/src/persona/protagonist-bind.ts new file mode 100644 index 0000000..8724656 --- /dev/null +++ b/src/persona/protagonist-bind.ts @@ -0,0 +1,238 @@ +/** + * 开场白 meta 里的用户角色(名字+简介)是本条开场的初始设定。 + * 用户选用后只覆盖本局 @玩家 / @人设,不写进全局角色列表。 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import { + CREATION_PLACEHOLDER_NAME, + personaForLifecycle, + type PersonaDirective, +} from "./store.js"; +import { + OPENING_PERSONA_CHOICE_TAG, + OPENING_PERSONA_TAG, + parseOpeningSealPayload, + readSealedOpeningPersona, + splitOpeningDocument, +} from "../skills/opening-seal.js"; + +export const PROTAGONIST_TAG = "设计.主角设定"; + +const RESERVED_NAMES = new Set(["@玩家", "用户", "主角", CREATION_PLACEHOLDER_NAME]); + +export type ProtagonistSpec = { + name: string; + background: string; + identity: string; + cheat: string; + extra: string; +}; + +export type SessionProtagonist = { + name: string; + description: string; + summary: string; +}; + +export function parseProtagonistSpec( + raw: string | null | undefined, +): ProtagonistSpec | null { + const text = raw?.trim() ?? ""; + if (!text) return null; + const fromJson = parseFromJson(text); + if (fromJson) return fromJson; + return parseFromProse(text); +} + +export function readSessionProtagonist( + board: Blackboard, +): SessionProtagonist | null { + const sealed = readSealedOpeningPersona( + board.getContentByTag(OPENING_PERSONA_TAG), + ); + const fromOpening = + sealed ?? + parseOpeningSealPayload(board.getContentByTag("设计.开场白与开场变量")) + ?.persona ?? + splitOpeningDocument(board.getContentByTag("设计.开场白")).persona; + if (fromOpening) { + return { + name: fromOpening.name, + description: fromOpening.description, + summary: firstSentence(fromOpening.description), + }; + } + const spec = parseProtagonistSpec(board.getContentByTag(PROTAGONIST_TAG)); + if (!spec) return null; + return { + name: spec.name, + description: formatPersonaDescription(spec), + summary: formatProtagonistSummary(spec), + }; +} + +export function formatPersonaDescription(spec: ProtagonistSpec): string { + const special: string[] = []; + if (spec.identity && spec.identity !== "无") special.push(`身份:${spec.identity}`); + if (spec.cheat && spec.cheat !== "无") special.push(`金手指:${spec.cheat}`); + if (spec.extra && spec.extra !== "无") special.push(spec.extra); + const parts: string[] = []; + if (spec.background && spec.background !== "未定") { + parts.push(`背景:${spec.background}`); + } + if (special.length) parts.push(`特殊设定:${special.join(";")}`); + return parts.join("\n").trim(); +} + +export function formatProtagonistSummary(spec: ProtagonistSpec): string { + const bits = [spec.identity, spec.cheat, spec.background] + .map((s) => firstSentence(s)) + .filter((s) => s && s !== "无" && s !== "未定"); + return bits.slice(0, 2).join(" · "); +} + +/** 用户选用开场预设后,用开场里的名字+简介替换 @玩家 / @人设。 */ +export function overlayPlayPersona( + stage: "design" | "play", + board: Blackboard | null | undefined, +): PersonaDirective | null { + const base = personaForLifecycle(stage); + if (stage !== "play" || !board || !base) return base; + const choice = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim(); + if (choice === "global") return base; + const preset = readSessionProtagonist(board); + if (!preset) return base; + if (choice === "opening") { + return { + name: preset.name, + description: preset.description || base.description, + }; + } + return base; +} + +function parseFromJson(text: string): ProtagonistSpec | null { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidates = fenced ? [fenced[1].trim(), text] : [text]; + for (const candidate of candidates) { + let doc: unknown; + try { + doc = JSON.parse(candidate); + } catch { + continue; + } + const spec = specFromUnknown(doc); + if (spec) return spec; + } + return null; +} + +function specFromUnknown(doc: unknown): ProtagonistSpec | null { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null; + const row = doc as Record; + const body = + row.正文 && typeof row.正文 === "object" && !Array.isArray(row.正文) + ? (row.正文 as Record) + : row; + const name = readName(body.名字 ?? body.name); + if (!name) return null; + const special = body.特殊设定; + const specialObj = + special && typeof special === "object" && !Array.isArray(special) + ? (special as Record) + : null; + const specialText = typeof special === "string" ? special.trim() : ""; + return { + name, + background: readText(body.背景 ?? body.background) || "未定", + identity: + readText(specialObj?.身份 ?? specialObj?.identity ?? body.身份) || + (specialText && !specialObj ? specialText : "") || + "无", + cheat: readText(specialObj?.金手指 ?? specialObj?.cheat ?? body.金手指) || "无", + extra: readText(specialObj?.其它 ?? specialObj?.extra ?? body.其它) || "无", + }; +} + +function parseFromProse(text: string): ProtagonistSpec | null { + const name = extractName( + matchField(text, "名字") || + matchField(text, "姓名") || + matchField(text, "name"), + ); + if (!name) return null; + const cheat = + matchField(text, "金手指") || matchField(text, "能做什么") || "无"; + const background = + matchField(text, "背景") || matchField(text, "关键背景") || "未定"; + return { + name, + background, + identity: matchField(text, "身份") || "无", + cheat, + extra: matchField(text, "其它") || matchField(text, "特殊设定") || "无", + }; +} + +function readName(raw: unknown): string | null { + return extractName(readText(raw)); +} + +function extractName(raw: string): string | null { + const stripped = stripMd(raw).trim(); + if (!stripped) return null; + const firstLine = stripped.split(/\n/)[0]?.trim() ?? ""; + const clause = firstLine.split(/[。!?;.!?;]/)[0]?.trim() ?? ""; + const noQuote = clause.replace(/^[「『“"']|[」』”"']$/g, "").trim(); + const noParen = noQuote.replace(/[((].*$/, "").trim(); + const candidate = noParen || noQuote; + if (!usableName(candidate)) return null; + if (candidate.length > 16) return null; + return candidate; +} + +function usableName(name: string): boolean { + const n = name.trim(); + if (!n) return false; + if (RESERVED_NAMES.has(n)) return false; + if (n.includes("@玩家")) return false; + if (/^(?待/.test(n)) return false; + return true; +} + +function readText(raw: unknown): string { + if (typeof raw !== "string") return ""; + return raw.trim(); +} + +function stripMd(text: string): string { + return text.replace(/\*\*/g, "").replace(/^#+\s*/, "").trim(); +} + +function firstSentence(text: string): string { + const flat = stripMd(text).replace(/\s+/g, " ").trim(); + if (!flat) return ""; + const clause = flat.split(/[。!?\n]/)[0]?.trim() ?? ""; + if (clause.length <= 36) return clause; + return `${clause.slice(0, 36)}…`; +} + +function matchField(text: string, label: string): string { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const sameLine = text.match(new RegExp(`${escaped}[::]\\s*([^\\n]+)`)); + if (sameLine?.[1]?.trim()) return sameLine[1].trim(); + return markdownSection(text, escaped); +} + +function markdownSection(text: string, escapedTitle: string): string { + const re = new RegExp( + `^#{1,3}\\s*${escapedTitle}(?=\\s|[((::]|$)[^\\n]*$`, + "im", + ); + const m = re.exec(text); + if (!m) return ""; + const rest = text.slice(m.index + m[0].length); + const next = rest.search(/^#{1,3}\s+/m); + const body = (next >= 0 ? rest.slice(0, next) : rest).trim(); + return body.split(/\n---\s*\n/)[0]?.trim() ?? ""; +} diff --git a/src/runtime/driver.ts b/src/runtime/driver.ts index a1887bc..279c653 100644 --- a/src/runtime/driver.ts +++ b/src/runtime/driver.ts @@ -14,7 +14,6 @@ import type { ToolDefinition, } from "../llm/client.js"; import { supportsToolStream } from "../llm/stream-complete.js"; -import { debugLog } from "../log.js"; export type DriverToolCall = { id: string; @@ -87,9 +86,10 @@ async function completeToolsPreferStream( messages: ChatMessage[], tools: ToolDefinition[], caller: string | undefined, + label: string | undefined, callbacks: StreamCallbacks, ): Promise { - const options = { tools, caller }; + const options = { tools, caller, label }; if (supportsToolStream(llm)) { return llm.completeWithToolsStream!(messages, options, callbacks); } @@ -115,12 +115,12 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver { 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, + `${label} 第${iteration}轮`, streamCallbacks, ); emitThinkingDone(input, result.reasoning); diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 444bcea..4094b41 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -36,7 +36,7 @@ import { toActiveSkillSnapshot } from "../skills/snapshot.js"; import { runWorkerSkill } from "../worker/executor.js"; import { resolveWorkerId } from "../worker/resolve-id.js"; import { resolveWorkerLlmProvider } from "../skills/worker-llm.js"; -import { personaForLifecycle } from "../persona/store.js"; +import { overlayPlayPersona } from "../persona/protagonist-bind.js"; import { DIALOGUE_HISTORY_TAG, appendDialogueHistoryTurn, @@ -127,9 +127,11 @@ import { OPENING_CURRENT_VARS_TAG, OPENING_INITIAL_VARS_TAG, OPENING_OUTPUT_TAG, + OPENING_PERSONA_TAG, OPENING_SETUP_ARTIFACT_TAG, parseOpeningSealPayload, reopenCreationFlowRaw, + serializeOpeningPersona, SLOT_CREATION_SEALED_BY_OPENING, SLOT_OPENING_SELECTED_INDEX, } from "../skills/opening-seal.js"; @@ -138,6 +140,10 @@ import { isDictateModeValue, } from "../dictate/types.js"; import { applyDictatePlayBind } from "../dictate/play-bind.js"; +import { + dictateProductFamily, + mergeGenerationRulesArtifacts, +} from "../dictate/repeatable-tags.js"; import { VARIABLE_CATALOG_TAG, seedVariablesFromCatalog, @@ -172,7 +178,9 @@ import { import { mergeTableCells, parseTableDoc, + patchUserTableCells, stringifyTableDoc, + type TableCellPatch, type TableDoc, } from "../blackboard/table-cells.js"; import { @@ -332,6 +340,16 @@ export class PhaseRuntime { return this.session; } + setOpeningSelectedIndex(index: number): void { + this.session = { + ...this.session, + slots: { + ...this.session.slots, + [SLOT_OPENING_SELECTED_INDEX]: Math.trunc(index), + }, + }; + } + getLastWorkerRunSnapshot(): { workerId: string; runtimeSession: RuntimeSession; @@ -741,6 +759,29 @@ export class PhaseRuntime { } } + /** + * 用户按格手改表:须带读到的 rev。版本冲突或只读格跳过,不整表覆盖。 + */ + patchUserTable( + tag: string, + patches: TableCellPatch[], + ): ReturnType { + const allowed = tag === "变量.当前" || tag === "运行.初始变量"; + if (!allowed) throw new Error("该表不可手改"); + const prev = parseTableDoc(this.blackboard.getContentByTag(tag)); + const result = patchUserTableCells(prev, patches); + if (result.applied.length) { + this.blackboard.write({ + tag, + content: stringifyTableDoc(result.doc), + source: "user", + }); + this.applyTableSideEffects(prev, result.doc, "user"); + if (tag === "变量.当前") this.applyValueMapProjections("user"); + } + return result; + } + /** * 用户文本输入的统一入口。 * legacy skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。 @@ -1944,6 +1985,14 @@ export class PhaseRuntime { this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup"); } + if (payload?.persona) { + this.writeWorkerTagContent( + OPENING_PERSONA_TAG, + serializeOpeningPersona(payload.persona), + "opening-setup", + ); + } + if (payload?.variables.length) { const source = "worker:opening-setup"; const initialPatch = mergeOpeningTablePatch( @@ -1995,15 +2044,15 @@ export class PhaseRuntime { /** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */ private ensurePlaySpecReady(): void { - // Boss 直聘:落档/开玩时自动挂载产物;种变量初值;按映射重投影 + // 对话落盘:落档/开玩时自动挂载产物;种变量初值;按映射重投影 if (this.isDictateIntakeMode()) { const bound = applyDictatePlayBind(this.blackboard); if (bound.boundTags.length) { this.onMessage( - `[Boss直聘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`, + `[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`, ); } else { - this.onMessage("[Boss直聘] 已生成默认游玩规格(尚无产物可挂载)"); + this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)"); } this.seedVariablesAndReproject("opening-setup"); } else { @@ -2274,7 +2323,7 @@ export class PhaseRuntime { slots, fallbackLlm: this.llm, getPersona: () => - personaForLifecycle(inferLifecycleStage(this.session)), + overlayPlayPersona(inferLifecycleStage(this.session), this.blackboard), }); this.onMessage( @@ -2670,7 +2719,19 @@ export class PhaseRuntime { need: { rule_id: string; reason: string }, workerId: string, ): void { - const rulesRaw = this.blackboard.getContentByTag("设计.生成规则"); + const board = this.blackboard; + const rawPieces: string[] = []; + for (const e of board.listTagIndex()) { + if (dictateProductFamily(e.tag) !== "设计.生成规则") continue; + const c = board.getContentByTag(e.tag)?.trim(); + if (c) rawPieces.push(c); + } + const rulesRaw = + rawPieces.length === 0 + ? board.getContentByTag("设计.生成规则") + : rawPieces.length === 1 + ? rawPieces[0] + : mergeGenerationRulesArtifacts(rawPieces); const result = runMaintainNeedGenerateSampling({ need, generationRulesRaw: rulesRaw, diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 0ca8786..cab97cd 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -56,6 +56,7 @@ async function loadModulesPayload(skillId: string): Promise<{ loadModuleCatalog, loadModulePrompt, parseModulePromptSections, + catalogModulesForIntake, MODULE_SECTION_IDS, } = await import("../skills/creation-flow.js"); const skill = await loadSkill(skillId); @@ -64,7 +65,7 @@ async function loadModulesPayload(skillId: string): Promise<{ } const catalog = await loadModuleCatalog(skill.skillPackRoot); const modules = []; - for (const m of catalog?.modules ?? []) { + for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "recipe")) { const prompt = await loadModulePrompt(skill.skillPackRoot, m.id); const sections = prompt ? parseModulePromptSections(prompt) @@ -423,16 +424,27 @@ export async function handleBooksApi( } try { let instanceId: string | undefined; + let useOpeningPersona: boolean | undefined; try { const raw = await readBody(req); if (raw.trim()) { - const body = JSON.parse(raw) as { instanceId?: string }; + const body = JSON.parse(raw) as { + instanceId?: string; + useOpeningPersona?: boolean; + }; instanceId = body.instanceId?.trim() || undefined; + useOpeningPersona = + typeof body.useOpeningPersona === "boolean" + ? body.useOpeningPersona + : undefined; } } catch { instanceId = undefined; + useOpeningPersona = undefined; } - const session = await sessionManager.startNewPlayRun(active.id, instanceId); + const session = await sessionManager.startNewPlayRun(active.id, instanceId, { + useOpeningPersona, + }); json(res, 200, { session }); } catch (err) { json(res, 400, { diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts index 964707c..3bf5307 100644 --- a/src/server/display-labels.ts +++ b/src/server/display-labels.ts @@ -1,7 +1,7 @@ /** * 用户可见中文标签(与 docs/ui-glossary.md 同步)。 * 内部 id 不变;仅展示层映射。 - * 口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元 + * 口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元 */ const STAGE_LABELS: Record = { @@ -39,9 +39,12 @@ const FIXED_TOPIC_LABELS: Record = { "aesthetics-interaction": "美学纲领与交互范式", interaction: "交互范式", narrative_guide: "叙事指南与故事推进", + "narrative-guide": "叙事指南", + "story-progression": "故事推进", input_protocol: "输入协议", core_premise: "核心前提", aesthetics: "美学纲领", + protagonist: "主角设定", }; const PHASE_UNIT_LABELS: Record = { @@ -49,9 +52,15 @@ const PHASE_UNIT_LABELS: Record = { refine: "细化", }; +const CREATION_MODE_LABELS: Record = { + recipe: "工序编排", + dictate: "对话落盘", +}; + const SKILL_PACK_LABELS: Record = { - "world-simulator": "世界模拟器", + "world-simulator": "回合推演", "expand-assistant": "扩写助手", + 快穿短局: "快穿短局", 文本生成器: "文本生成器", "text-generator": "文本生成器", 交互式长文生成器: "交互式长文生成器", @@ -64,6 +73,12 @@ export function displayStageLabel(id: string | undefined | null): string { return STAGE_LABELS[id] ?? id; } +/** 进料方式展示名(新建对话框 / 工作面 kicker) */ +export function displayCreationModeLabel(id: string | undefined | null): string { + if (!id) return ""; + return CREATION_MODE_LABELS[id] ?? id; +} + /** 配方选项 / skill pack 展示名 */ export function displaySkillPackLabel(id: string | undefined | null): string { if (!id) return ""; diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 8657d93..822906a 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -18,6 +18,13 @@ import { import { INSTANCE_OPENING_SNAPSHOT_LABEL, OPENING_OUTPUT_TAG, + OPENING_PERSONA_CHOICE_TAG, + OPENING_PERSONA_TAG, + SLOT_OPENING_SELECTED_INDEX, + parseOpeningSealPayload, + readSealedOpeningPersona, + serializeOpeningPersona, + splitOpeningDocument, } from "../skills/opening-seal.js"; import { PLAY_WORKING_SNAPSHOT_ID, @@ -150,9 +157,11 @@ import { CREATION_INTAKE_MODE_TAG, DICTATE_MODE_VALUE, DICTATE_ORDER_META_KEY, + buildDictateInsertFeedbackIndex, defaultDictateOrder, extractDictateDialogue, isDictateModeValue, + lookupDictateInsertFeedback, runDictateTurn, sortDictateProducts, type DictateProduct, @@ -173,6 +182,12 @@ import { listPersonas, personaForLifecycle, } from "../persona/store.js"; +import { + overlayPlayPersona, + readSessionProtagonist, +} from "../persona/protagonist-bind.js"; +import { buildPlayAuxView } from "../skills/play-aux.js"; +import type { TableCellPatch } from "../blackboard/table-cells.js"; import { appendBranchVariant, cloneCheckpoint, @@ -290,6 +305,11 @@ export type SessionView = { agentThinking?: string; /** Agent / Worker 流式输出(轮询用) */ liveStream?: LiveStreamView; + /** + * 会话是否有进行中的排他运行(send/action 的 runExclusive)。 + * 前端 loading 应对齐此标志,避免 POST 挂住时底栏永远「生成中」。 + */ + runActive?: boolean; resumed?: boolean; /** 创作进料:recipe=配方节点流;dictate=转述整理直填 */ creationMode?: "recipe" | "dictate"; @@ -310,6 +330,12 @@ export type SessionView = { personas?: Array<{ id: string; name: string }>; /** 当前选中的用户角色 */ activePersona?: { id: string; name: string; description: string } | null; + /** 本局开场自带的用户角色预设(名字+简介);关掉这局就不加载 */ + sessionProtagonist?: { name: string; description: string; summary: string } | null; + /** 用户是否选用开场预设:opening / global */ + openingPersonaChoice?: "opening" | "global" | null; + /** 游玩辅助 Tab:表格 / 开场与角色 */ + playAux?: import("../skills/play-aux.js").PlayAuxView; }; export type SessionAction = @@ -419,14 +445,21 @@ export class SessionManager { return this.createForBook(); } - setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView { + setLifecycleStage( + sessionId: string, + stage: LifecycleStage, + opts?: { useOpeningPersona?: boolean }, + ): SessionView { const s = this.require(sessionId); if (stage === "play") { s.runtime.prepareEnterPlay(); if (!canEnterPlay(s.runtime.getSession())) { throw new Error("实例尚未就绪,无法进入游玩"); } - this.enterPlayLayer(s, { resume: true }); + this.enterPlayLayer(s, { + resume: true, + useOpeningPersona: opts?.useOpeningPersona, + }); } else { this.enterDesignLayer(s); } @@ -452,6 +485,65 @@ export class SessionManager { return this.toView(sessionId); } + /** 游玩表按格手改。冲突格在 tablePatch.skipped,整表不会被旧内容盖掉。 */ + patchPlayTable( + sessionId: string, + tag: string, + cells: TableCellPatch[], + ): SessionView & { + tablePatch: ReturnType; + } { + const s = this.require(sessionId); + const tablePatch = s.runtime.patchUserTable(tag.trim() || "变量.当前", cells); + if (s.bookId) this.persist(s); + return { ...this.toView(sessionId), tablePatch }; + } + + /** + * 游玩辅助面点选开场。usePersona=true 用该开场自带角色;false 保持当前用户角色卡。 + */ + selectPlayOpening( + sessionId: string, + index: number, + usePersona?: boolean, + ): SessionView { + const s = this.require(sessionId); + const board = s.runtime.getBlackboard(); + const aux = buildPlayAuxView(board, s.runtime.getSession().slots); + if (!aux.openings.length) throw new Error("没有可选开场"); + const idx = Math.min(Math.max(0, Math.trunc(index)), aux.openings.length - 1); + const picked = aux.openings[idx]!; + s.runtime.setOpeningSelectedIndex(idx); + + const persona = picked.persona; + if (usePersona === false) { + this.writeOpeningPersonaChoice(s, false); + } else if (persona) { + board.write({ + tag: OPENING_PERSONA_TAG, + content: serializeOpeningPersona(persona), + source: "user", + }); + this.writeOpeningPersonaChoice(s, true); + } + + const expanded = this.expandDirectivesText( + picked.text, + this.playDirectiveContext(s), + ); + board.write({ + tag: OPENING_OUTPUT_TAG, + content: expanded, + source: "user", + }); + const first = s.messages[0]; + if (first?.title === "开场白") { + s.messages[0] = this.playOpeningMessage(expanded); + } + if (s.bookId) this.persist(s); + return this.toView(sessionId); + } + /** * 编排上下文投影排序:写入 设计.worker集.context_order(若有规格), * 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。 @@ -625,7 +717,7 @@ export class SessionManager { return this.toView(id); } - /** 切换到转述整理进料(与配方并行;不跑 design-flow) */ + /** 切换到对话落盘进料(与工序编排并行;不跑 design-flow) */ private enterDictateMode(s: ManagedSession): void { const board = s.runtime.getBlackboard(); board.write({ @@ -641,7 +733,7 @@ export class SessionManager { }。` : ""; const welcome = - `Boss 直聘模式:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`; + `对话落盘:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`; s.messages.push({ id: randomUUID(), role: "system", @@ -649,7 +741,7 @@ export class SessionManager { createdAt: new Date().toISOString(), kind: "orchestrator_prompt", actor: "orchestrator", - title: "Boss直聘", + title: "对话落盘", body: welcome, }); } @@ -726,6 +818,13 @@ export class SessionManager { skillName: s.runtime.getActiveSkill()?.name, moduleCatalog: s.moduleCatalog, }); + const insertFeedbackIndex = await resolveDictateInsertFeedbackIndexForSession({ + skillName: s.runtime.getActiveSkill()?.name, + moduleCatalog: s.moduleCatalog, + }); + const repeatableFamilies = (s.moduleCatalog?.modules ?? []) + .filter((m) => m.repeatable === true && m.artifact?.trim()) + .map((m) => m.artifact!.trim()); const result = await runDictateTurn({ llm, @@ -733,6 +832,7 @@ export class SessionManager { recipeName: recipeView?.name, recipeBrief, moduleGuide, + repeatableFamilies, handlers: { listProducts: () => this.listDictateProducts(s), writeProduct: (tag, content, order) => { @@ -755,6 +855,7 @@ export class SessionManager { }, }); }, + deleteProduct: (tag) => s.runtime.getBlackboard().deleteByTag(tag), readTag: (tag) => s.runtime.getBlackboard().getContentByTag(tag), writeTag: (tag, content) => { const board = s.runtime.getBlackboard(); @@ -768,6 +869,7 @@ export class SessionManager { }, }); }, + deleteTag: (tag) => s.runtime.getBlackboard().deleteByTag(tag), clearDialogue: () => { const kept = this.listDictateProducts(s); const lastUser = [...s.messages] @@ -813,6 +915,8 @@ export class SessionManager { if (text.trim()) this.agentThinkingLive.set(id, text); this.agentThinkingClosed.add(id); }, + lookupInsertFeedback: (tag) => + lookupDictateInsertFeedback(insertFeedbackIndex, tag), }, }); @@ -949,6 +1053,7 @@ export class SessionManager { async startNewPlayRun( sessionId: string, instanceId?: string, + opts?: { useOpeningPersona?: boolean }, ): Promise { const s = this.require(sessionId); if (!s.bookId) throw new Error("仅绑定作品时可新建游玩"); @@ -958,14 +1063,22 @@ export class SessionManager { throw new Error("须先验收 Worker 集、完成收口,或(转述)先写入产物并落档,才能开始游玩"); } this.archivePlayWorking(s.bookId); - this.enterPlayLayer(s, { resume: false, instanceId }); + this.enterPlayLayer(s, { + resume: false, + instanceId, + useOpeningPersona: opts?.useOpeningPersona, + }); return this.toView(sessionId); } /** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */ private enterPlayLayer( s: ManagedSession, - opts: { resume: boolean; instanceId?: string }, + opts: { + resume: boolean; + instanceId?: string; + useOpeningPersona?: boolean; + }, ): void { this.persistCreationIfDesign(s); s.runtime.prepareEnterPlay(); @@ -978,6 +1091,7 @@ export class SessionManager { isPlayLayerActive(session.slots) && inferLifecycleStage(session) === "play" ) { + this.writeOpeningPersonaChoice(s, opts.useOpeningPersona); this.sanitizePlayHitl(s); return; } @@ -986,10 +1100,11 @@ export class SessionManager { const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID); if (working?.kind === "run") { this.restoreSnapshotInPlace(s, working, "play"); + this.writeOpeningPersonaChoice(s, opts.useOpeningPersona); return; } } - this.beginNewPlayFromInstance(s, opts.instanceId); + this.beginNewPlayFromInstance(s, opts.instanceId, opts.useOpeningPersona); if (s.bookId) this.savePlayWorking(s); } @@ -1009,7 +1124,11 @@ export class SessionManager { this.restoreCreationFromDisk(s); } - private beginNewPlayFromInstance(s: ManagedSession, instanceId?: string): void { + private beginNewPlayFromInstance( + s: ManagedSession, + instanceId?: string, + useOpeningPersona?: boolean, + ): void { const keepId = s.runtime.getSession().id; // 换上 instance 快照前先记下当前创作里的开场白(可能比上次定稿更新) const liveDesignOpening = @@ -1055,6 +1174,7 @@ export class SessionManager { source: "runtime", }); } + this.writeOpeningPersonaChoice(s, useOpeningPersona); const opening = this.ensurePlayOpeningWritten( s, instance?.messages as ChatMessage[] | undefined, @@ -1072,6 +1192,19 @@ export class SessionManager { s.branchState = createMessageBranchState(); } + /** 开玩时记下是否用这条开场自带的用户角色;须在恢复快照之后写,否则会被冲掉。 */ + private writeOpeningPersonaChoice( + s: ManagedSession, + useOpeningPersona?: boolean, + ): void { + if (useOpeningPersona === undefined) return; + s.runtime.getBlackboard().write({ + tag: OPENING_PERSONA_CHOICE_TAG, + content: useOpeningPersona ? "opening" : "global", + source: "user", + }); + } + /** * 保证黑板上有 输出.开场白,供开玩主区展示。 * 优先级:设计.开场白(转述产物)> 已有 输出.开场白 > 其它产物拼装 > 非转述聊天兜底。 @@ -1082,6 +1215,7 @@ export class SessionManager { s: ManagedSession, messageSource?: ChatMessage[], ): string { + this.sealOpeningPersonaFromBoard(s); const board = s.runtime.getBlackboard(); const fromDesign = board.getContentByTag("设计.开场白")?.trim() || @@ -1089,9 +1223,10 @@ export class SessionManager { ?.content?.trim() || ""; if (fromDesign) { + const body = splitOpeningDocument(fromDesign).text || fromDesign; const expanded = this.expandDirectivesText( - fromDesign, - this.playDirectiveContext(), + body, + this.playDirectiveContext(s), ); const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; if (existing !== expanded) { @@ -1108,7 +1243,7 @@ export class SessionManager { if (existing) { const expanded = this.expandDirectivesText( existing, - this.playDirectiveContext(), + this.playDirectiveContext(s), ); if (expanded !== existing) { board.write({ @@ -1129,7 +1264,7 @@ export class SessionManager { const expanded = this.expandDirectivesText( opening, - this.playDirectiveContext(), + this.playDirectiveContext(s), ); board.write({ tag: OPENING_OUTPUT_TAG, @@ -1139,19 +1274,46 @@ export class SessionManager { return expanded; } + /** 开场 meta 里的用户角色:落到 运行.开场用户角色,不进全局列表。 */ + private sealOpeningPersonaFromBoard(s: ManagedSession): void { + const board = s.runtime.getBlackboard(); + if (readSealedOpeningPersona(board.getContentByTag(OPENING_PERSONA_TAG))) { + return; + } + const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX]; + const selectedIndex = + typeof selectedRaw === "number" + ? selectedRaw + : typeof selectedRaw === "string" && selectedRaw.trim() + ? Number(selectedRaw) + : 0; + const persona = + parseOpeningSealPayload( + board.getContentByTag("设计.开场白与开场变量"), + selectedIndex, + )?.persona ?? splitOpeningDocument(board.getContentByTag("设计.开场白")).persona; + if (!persona) return; + board.write({ + tag: OPENING_PERSONA_TAG, + content: serializeOpeningPersona(persona), + source: "runtime", + }); + } + private personaForManaged(s: ManagedSession): ReturnType< typeof personaForLifecycle > { if (!s.runtime) return personaForLifecycle("design"); - return personaForLifecycle(inferLifecycleStage(s.runtime.getSession())); + return overlayPlayPersona( + inferLifecycleStage(s.runtime.getSession()), + s.runtime.getBlackboard(), + ); } - private playDirectiveContext(): DirectiveContext { - const p = getActivePersona(); + private playDirectiveContext(s: ManagedSession): DirectiveContext { + const p = this.personaForManaged(s); if (!p) return {}; - return { - persona: { name: p.name, description: p.description }, - }; + return { persona: p }; } private directiveContextFor(s: ManagedSession): DirectiveContext { @@ -3097,6 +3259,7 @@ export class SessionManager { private toView(id: string, resumed = false, resumeHint?: string): SessionView { const s = this.require(id); this.sanitizePlayHitl(s); + this.sealOpeningPersonaFromBoard(s); const session = s.runtime.getSession(); const reason = session.waitingReason; const book = s.bookId ? getBook(s.bookId) : null; @@ -3482,6 +3645,7 @@ export class SessionManager { : undefined, liveStream: this.buildLiveStreamView(id), agentThinking: this.agentThinkingLive.get(id), + runActive: Boolean(s.runAbort), resumed: resumed || undefined, tokenStats: { sessionTotal: tokens.totalTokens, @@ -3498,6 +3662,19 @@ export class SessionManager { ? { id: p.id, name: p.name, description: p.description } : null; })(), + sessionProtagonist: readSessionProtagonist(s.runtime.getBlackboard()), + openingPersonaChoice: ((): "opening" | "global" | null => { + const raw = s.runtime + .getBlackboard() + .getContentByTag(OPENING_PERSONA_CHOICE_TAG) + ?.trim(); + if (raw === "opening" || raw === "global") return raw; + return null; + })(), + playAux: buildPlayAuxView( + s.runtime.getBlackboard(), + s.runtime.getSession().slots, + ), }; } @@ -3927,7 +4104,7 @@ function resolveSelectedRecipeView( return { id: ref, name: ref, declaration: "" }; } -/** Boss 直聘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */ +/** 对话落盘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */ async function resolveDictateRecipeBriefForSession(params: { skillName?: string | null; recipeView: { id: string; name: string; declaration: string } | null; @@ -3956,7 +4133,7 @@ async function resolveDictateRecipeBriefForSession(params: { return view.declaration?.trim() || undefined; } -/** Boss 直聘:注入能力何时落盘(modules meta);优先会话已载 catalog */ +/** 对话落盘:注入能力何时落盘(modules meta);优先会话已载 catalog */ async function resolveDictateModuleGuideForSession(params: { skillName?: string | null; moduleCatalog?: ModuleCatalog | null; @@ -3979,6 +4156,38 @@ async function resolveDictateModuleGuideForSession(params: { return formatModuleCatalogForDictate(catalog); } +/** 对话落盘:insert(tag) → 用户可见规范索引 */ +async function resolveDictateInsertFeedbackIndexForSession(params: { + skillName?: string | null; + moduleCatalog?: ModuleCatalog | null; +}): Promise | undefined> { + let catalog = params.moduleCatalog ?? null; + let packRoot: string | undefined; + const skillName = params.skillName?.trim(); + if (skillName) { + try { + const skill = await loadSkill(skillName); + packRoot = skill.skillPackRoot?.trim() || undefined; + if (!catalog?.modules?.length && packRoot) { + catalog = await loadModuleCatalog(packRoot); + } + } catch (err) { + console.error("[会话] 加载 insert 反馈索引失败", err); + return undefined; + } + } + if (!catalog?.modules?.length || !packRoot) return undefined; + try { + return await buildDictateInsertFeedbackIndex({ + catalog, + skillPackRoot: packRoot, + }); + } catch (err) { + console.error("[会话] 构建 insert 反馈索引失败", err); + return undefined; + } +} + function buildWorkerSetUserView( blackboard: Blackboard, session?: RuntimeSession, diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index f0e79f9..409de49 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -136,7 +136,10 @@ export async function handleSettingsApi( if (pathname === "/api/profiles" && req.method === "GET") { ensureActiveProfileDefault(); - json(res, 200, { profiles: listApiProfiles() }); + json(res, 200, { + profiles: listApiProfiles(), + activeProfileId: loadAppSettings().activeProfileId, + }); return true; } diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 5cda9ea..4a23a65 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -37,7 +37,14 @@ async function serveStatic(res: ServerResponse, filePath: string): Promise ".js": "application/javascript; charset=utf-8", }; const content = await readFile(filePath); - res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" }); + const headers: Record = { + "Content-Type": types[ext] ?? "application/octet-stream", + }; + // 开发期:避免 ES module(agent-ui.js 等)被强缓存,改完 import 仍吃旧脚本导致主区空白 + if (ext === ".html" || ext === ".js" || ext === ".css") { + headers["Cache-Control"] = "no-cache, must-revalidate"; + } + res.writeHead(200, headers); res.end(content); } @@ -339,13 +346,21 @@ const server = createServer(async (req, res) => { } if (req.method === "POST" && sub === "/lifecycle") { - const body = JSON.parse(await readBody(req)) as { stage?: string }; + const body = JSON.parse(await readBody(req)) as { + stage?: string; + useOpeningPersona?: boolean; + }; if (body.stage !== "design" && body.stage !== "play") { json(res, 400, { error: "stage 须为 design 或 play" }); return; } try { - const view = sessionManager.setLifecycleStage(sessionId, body.stage); + const view = sessionManager.setLifecycleStage(sessionId, body.stage, { + useOpeningPersona: + typeof body.useOpeningPersona === "boolean" + ? body.useOpeningPersona + : undefined, + }); json(res, 200, view); } catch (err) { json(res, 400, { @@ -399,6 +414,68 @@ const server = createServer(async (req, res) => { return; } + if (req.method === "POST" && sub === "/table") { + const body = JSON.parse(await readBody(req)) as { + tag?: string; + cells?: Array<{ key?: string; value?: unknown; expectedRev?: number }>; + }; + const cells = Array.isArray(body.cells) + ? body.cells + .filter( + (c) => + typeof c?.key === "string" && + c.key.trim() && + typeof c.expectedRev === "number", + ) + .map((c) => ({ + key: String(c.key).trim(), + value: c.value, + expectedRev: c.expectedRev as number, + })) + : []; + if (!cells.length) { + json(res, 400, { error: "缺少 cells(每格需要 key 与 expectedRev)" }); + return; + } + try { + const view = sessionManager.patchPlayTable( + sessionId, + body.tag?.trim() || "变量.当前", + cells, + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "表写入失败", + }); + } + return; + } + + if (req.method === "POST" && sub === "/play/opening") { + const body = JSON.parse(await readBody(req)) as { + index?: number; + usePersona?: boolean; + }; + if (typeof body.index !== "number" || !Number.isFinite(body.index)) { + json(res, 400, { error: "缺少 index" }); + return; + } + try { + const view = sessionManager.selectPlayOpening( + sessionId, + body.index, + typeof body.usePersona === "boolean" ? body.usePersona : undefined, + ); + json(res, 200, view); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "无法选用开场", + }); + } + return; + } + if (req.method === "POST" && sub === "/context-order") { const body = JSON.parse(await readBody(req)) as { action?: string; diff --git a/src/skills/context-order.ts b/src/skills/context-order.ts index d4a3deb..959b396 100644 --- a/src/skills/context-order.ts +++ b/src/skills/context-order.ts @@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record< "设计.变量设计与更新规则", "设计.生成规则", "设计.叙事指南与故事推进", + "设计.叙事指南", + "设计.故事推进", "设计.正文组成", "设计.监控栏", ], @@ -525,6 +527,8 @@ const SYNTH_FALLBACK_CONTEXT: Record< narrator: { static: [ "设计.叙事指南与故事推进", + "设计.叙事指南", + "设计.故事推进", "设计.正文组成", "设计.监控栏", ], diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index 0f38fa7..007e76a 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -206,6 +206,11 @@ export type ModuleCatalogEntry = { * 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。 */ params?: ModuleParamSpec[]; + /** + * 可选:进料族。缺省(不写)= 工序编排与对话落盘都能见。 + * recipe = 只进工序编排 DAG;dictate = 只进对话落盘何时落盘。 + */ + intake?: RecipeIntakeFamily; /** 来自 prompt.md ```meta:何时该选用(编排选型) */ when?: string; /** 来自 prompt.md ```meta:何时不该选用 */ @@ -214,6 +219,27 @@ export type ModuleCatalogEntry = { boundary?: string; }; +export function parseModuleIntake(raw: unknown): RecipeIntakeFamily | undefined { + if (raw === "recipe" || raw === "dictate") return raw; + return undefined; +} + +/** 缺省 intake = 两族都可见 */ +export function moduleVisibleForIntake( + module: Pick, + family: RecipeIntakeFamily, +): boolean { + if (!module.intake) return true; + return module.intake === family; +} + +export function catalogModulesForIntake( + catalog: ModuleCatalog, + family: RecipeIntakeFamily, +): ModuleCatalogEntry[] { + return catalog.modules.filter((m) => moduleVisibleForIntake(m, family)); +} + /** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */ export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白"; /** JSON:{ [步骤中文名]: "shown" | "answered" } */ @@ -924,6 +950,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null { const auto = m.auto === true; const kind = parseModuleNodeKind(m.kind); const params = parseModuleParamSpecs(m.params); + const intake = parseModuleIntake(m.intake); modules.push({ id, name, @@ -935,6 +962,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null { ...(kind ? { kind } : {}), ...(opening ? { opening } : {}), ...(params ? { params } : {}), + ...(intake ? { intake } : {}), }); } if (modules.length === 0) return null; @@ -1240,8 +1268,9 @@ function slugFromName(name: string): string { 美学纲领与交互范式: "aesthetics-interaction", 交互范式: "interaction", 美学纲领: "aesthetics", - 叙事指南: "narrative", + 叙事指南: "narrative-guide", 叙事指南与故事推进: "narrative", + 故事推进: "story-progression", 实现机制: "mechanism", 舞台骨架: "world-blueprint", 世界蓝图与人文地理: "world-blueprint", // 旧称 @@ -1283,7 +1312,7 @@ export async function loadModuleCatalog( /** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { - const lines = catalog.modules.map((m) => { + const lines = catalogModulesForIntake(catalog, "recipe").map((m) => { const flags = [ m.repeatable ? "〔可反复〕" : "", m.closer ? "〔收口〕" : "", @@ -1332,7 +1361,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { ].join("\n"); } -/** Boss 直聘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */ +/** 对话落盘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([ "worker-spec", "context-order", @@ -1340,11 +1369,11 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([ ]); /** - * 注入 Boss 直聘:能力何时落盘(meta when/when_not)+ insert 目标 tag。 + * 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。 * 不写 DAG / design-step 话术;不塞执行全文。 */ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { - const lines = catalog.modules.map((m) => { + const lines = catalogModulesForIntake(catalog, "dictate").map((m) => { const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto; const flags = [ m.repeatable ? "〔可反复〕" : "", @@ -1366,11 +1395,13 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { }); return [ "【能力 · 何时落盘】", - "对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落、只短确认或问挡住的 1 点。", - "每轮通常只推进条件已成立的一刀;勿因用户一次说了很多就批量抢跑下游。", + "对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落该能力,改为短确认或问挡住的 1 点。", + "同轮可多次落盘:凡本轮材料已够且「何时用」成立的能力,都可 insert / declare_*(可并行);每个 insert 各回 reply_module,可见回复分段附带。", + "不抢跑:条件未成立的下游本轮不落,放进「建议下一刀」。", "标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。", - "标〔收口〕的开场白:美学与必要上游齐后再写;未齐不硬塞。", - "不要把能力执行全文塞进聊天;规格只走 toolcall。", + "标〔收口〕的开场白:美学与必要上游齐后再写;未齐则先补上游。", + "标〔可反复〕的(生成规则/具体实例等):每条增殖用「基名#唯一id」拆分落盘(如 设计.具体实例#lei-ying);禁止反复 insert 基名覆盖旧条。改某一条则 insert 同一完整 tag。", + "能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。", lines.join("\n"), ].join("\n"); } @@ -1383,7 +1414,17 @@ function dictateLandHint(m: ModuleCatalogEntry): string { return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」"; } if (m.id === "opening-setup") { - return "insert 「设计.开场白」(Boss 直聘简化 tag;初值与开场同真相)"; + return "insert 「设计.开场白」(对话落盘简化 tag;初值与开场同真相)"; + } + if (m.artifact?.trim() && m.repeatable) { + const art = m.artifact.trim(); + const slotHint = + m.id === "generation-rules" + ? "rule_id" + : m.id === "concrete-instances" + ? "batch_id或姓名" + : "唯一id"; + return `insert 「${art}#${slotHint}」——可增殖:每条对象/批次各占一 tag;同完整 tag 才覆盖改写,禁止反复写基名「${art}」覆盖`; } if (m.artifact?.trim()) { return `insert 「${m.artifact.trim()}」`; @@ -1618,7 +1659,7 @@ export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string { } /** - * Boss 直聘 / 转述进料:把已选配方方法论收成短要点(进系统提示「本局配方」)。 + * 对话落盘:把已选配方方法论收成短要点(进系统提示「本局配方」)。 * 不含 DAG steps / design-flow 选型话术。 */ export function formatDictateRecipeBrief(detail: RecipeDetail): string { @@ -1744,7 +1785,7 @@ export function formatFlowProgressForAgent(params: { const catalog = params.catalog ?? null; const filled = new Set(params.filledArtifactTags ?? []); const repeatableNames = new Set( - (catalog?.modules ?? []) + (catalog ? catalogModulesForIntake(catalog, "recipe") : []) .filter((m) => m.repeatable === true) .map((m) => m.name), ); @@ -1793,7 +1834,7 @@ export function formatFlowProgressForAgent(params: { } if (catalog) { - for (const mod of catalog.modules) { + for (const mod of catalogModulesForIntake(catalog, "recipe")) { if (mod.repeatable === true) continue; if (!mod.artifact || !filled.has(mod.artifact)) continue; if (doneNames.has(mod.name)) continue; @@ -1812,7 +1853,10 @@ export function formatFlowProgressForAgent(params: { } } - const repeatableList = (catalog?.modules ?? []) + const repeatableList = (catalog + ? catalogModulesForIntake(catalog, "recipe") + : [] + ) .filter((m) => m.repeatable === true) .map((m) => m.name); @@ -1940,7 +1984,9 @@ export function validateCreationFlow( const errors: string[] = []; const seenIds = new Set(); const allowed = catalog - ? new Set(catalog.modules.map((m) => m.name)) + ? new Set( + catalogModulesForIntake(catalog, "recipe").map((m) => m.name), + ) : null; const reportedFreshDup = new Set(); @@ -2224,7 +2270,7 @@ export function listCallableCatalogModules(params: { (params.acceptedStepIds ?? []).map((id) => id.trim()).filter(Boolean), ); const out: CreationFlowAvailableModule[] = []; - for (const mod of catalog.modules) { + for (const mod of catalogModulesForIntake(catalog, "recipe")) { if (scheduled.has(mod.name)) continue; if (!mod.repeatable) { if (mod.artifact && filled.has(mod.artifact)) continue; @@ -2598,7 +2644,7 @@ export function listRepeatableSpawns( catalog: ModuleCatalog, acceptedStepIds: readonly string[], ): CreationFlowSpawnView[] { - return catalog.modules + return catalogModulesForIntake(catalog, "recipe") .filter((m) => m.repeatable === true && !m.closer) .filter( (m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)), diff --git a/src/skills/opening-seal.ts b/src/skills/opening-seal.ts index 26e6472..6a1a6e6 100644 --- a/src/skills/opening-seal.ts +++ b/src/skills/opening-seal.ts @@ -12,11 +12,20 @@ import { stringifyCreationFlow, } from "./creation-flow.js"; import { parseContextFragment } from "./context-fragment.js"; +import { + isPresentLikeObject, + normalizePresentPacket, + stringifyPresentPacket, +} from "./present-packet.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 OPENING_PERSONA_TAG = "运行.开场用户角色"; +/** opening = 本局用开场预设;global = 保持当前用户角色 */ +export const OPENING_PERSONA_CHOICE_TAG = "运行.开场用户角色.选用"; export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening"; export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex"; @@ -58,11 +67,17 @@ export type OpeningVariable = { note?: string; }; +export type OpeningUserPersona = { + name: string; + description: string; +}; + export type OpeningSealPayload = { candidates: string[]; selectedIndex: number; selectedText: string; variables: OpeningVariable[]; + persona: OpeningUserPersona | null; }; function asTrimmed(v: unknown): string | undefined { @@ -71,22 +86,96 @@ function asTrimmed(v: unknown): string | undefined { return t || undefined; } -function collectCandidateTexts(raw: unknown): string[] { +type OpeningCandidateEntry = { + text: string; + persona: OpeningUserPersona | null; + variables: OpeningVariable[]; +}; + +function asOpeningBodyText(raw: unknown): string | undefined { + if (typeof raw === "string") { + const t = raw.trim(); + if (!t) return undefined; + if (t.startsWith("{")) { + try { + const doc = JSON.parse(t) as unknown; + if ( + isPresentLikeObject(doc) || + (doc && + typeof doc === "object" && + !Array.isArray(doc) && + (doc as Record).blocks != null) + ) { + return stringifyPresentPacket( + normalizePresentPacket(doc as Record), + ); + } + } catch { + /* 散文 */ + } + } + return t; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const row = raw as Record; + if ( + isPresentLikeObject(row) || + row.blocks != null || + typeof row.shell === "string" || + typeof row.shell_id === "string" + ) { + return stringifyPresentPacket(normalizePresentPacket(row)); + } + return undefined; +} + +function variablesFromCandidate(row: Record): OpeningVariable[] { + const fromRow = collectVariables(row.开场变量); + if (fromRow.length) return fromRow; + const meta = row.meta; + if (meta && typeof meta === "object" && !Array.isArray(meta)) { + return collectVariables((meta as Record).开场变量); + } + return []; +} + +function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] { if (!Array.isArray(raw)) return []; - const out: string[] = []; + const out: OpeningCandidateEntry[] = []; for (const item of raw) { if (typeof item === "string" && item.trim()) { - out.push(item.trim()); + const split = splitYamlFrontmatter(item); + out.push({ + text: asOpeningBodyText(split.text) || split.text, + persona: split.persona, + variables: [], + }); 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); + asOpeningBodyText(row.正文) || + asOpeningBodyText(row.全文) || + asOpeningBodyText(row.text) || + asOpeningBodyText(row.开场白) || + asOpeningBodyText(row.内容); + if (!text) continue; + const split = splitYamlFrontmatter(text); + const persona = + parseUserPersonaField(row.用户角色) || + parseUserPersonaField(row.meta) || + parseUserPersonaField( + row.meta && typeof row.meta === "object" && !Array.isArray(row.meta) + ? (row.meta as Record).用户角色 + : undefined, + ) || + split.persona; + out.push({ + text: asOpeningBodyText(split.text) || split.text, + persona, + variables: variablesFromCandidate(row), + }); } } return out; @@ -111,6 +200,128 @@ function collectVariables(raw: unknown): OpeningVariable[] { return out; } +export function parseUserPersonaField(raw: unknown): OpeningUserPersona | null { + if (!raw) return null; + if (typeof raw === "string") return parseOpeningUserPersona(raw); + if (typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const nested = row.用户角色; + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + const fromNested = parseUserPersonaField(nested); + if (fromNested) return fromNested; + } + const name = + asTrimmed(row.名字) || asTrimmed(row.name) || asTrimmed(row.姓名) || ""; + if (!name || name === "@玩家" || name === "用户" || name === "主角") return null; + const description = + asTrimmed(row.简介) || + asTrimmed(row.description) || + asTrimmed(row.人设) || + asTrimmed(row.描述) || + ""; + return { name, description }; +} + +function splitYamlFrontmatter(raw: string): { + text: string; + persona: OpeningUserPersona | null; +} { + const text = raw.trim(); + const fence = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!fence) return { text, persona: parseOpeningUserPersona(text) }; + const persona = + parseUserPersonaField(parseSimpleYamlMap(fence[1] ?? "")) || + parseOpeningUserPersona(fence[1] ?? ""); + return { text: (fence[2] ?? "").trim(), persona }; +} + +/** 开场正文前的 YAML 式 meta:用户角色名字+简介,其余当正文。 */ +export function splitOpeningDocument(raw: string | null | undefined): { + text: string; + persona: OpeningUserPersona | null; +} { + const text = raw?.trim() ?? ""; + if (!text) return { text: "", persona: null }; + const payload = parseOpeningSealPayload(text, 0); + if (payload?.selectedText) { + return { text: payload.selectedText, persona: payload.persona }; + } + return splitYamlFrontmatter(text); +} + +function parseSimpleYamlMap(raw: string): Record { + const lines = raw.split(/\r?\n/); + const root: Record = {}; + let currentKey = ""; + let currentObj: Record | null = null; + for (const line of lines) { + const nested = line.match(/^[ \t]+([^\s:]+)\s*:\s*(.*)$/); + if (nested && currentObj) { + currentObj[nested[1]] = nested[2].trim(); + continue; + } + const top = line.match(/^([^\s:]+)\s*:\s*(.*)$/); + if (!top) continue; + const key = top[1]; + const val = top[2].trim(); + if (val === "" || val === "|" || val === ">") { + currentKey = key; + currentObj = {}; + root[key] = currentObj; + continue; + } + currentKey = ""; + currentObj = null; + root[key] = val; + } + void currentKey; + return root; +} + +export function parseOpeningUserPersona( + raw: string | null | undefined, +): OpeningUserPersona | null { + const text = raw?.trim() ?? ""; + if (!text) return null; + try { + const doc = JSON.parse(text) as unknown; + const fromJson = + parseUserPersonaField(doc) || + parseUserPersonaField( + doc && typeof doc === "object" && !Array.isArray(doc) + ? (doc as Record).用户角色 + : undefined, + ); + if (fromJson) return fromJson; + } catch { + /* not json */ + } + const name = + text.match(/(?:用户角色\.)?名字[::]\s*([^\n]+)/)?.[1]?.trim() || + text.match(/^名字[::]\s*([^\n]+)/m)?.[1]?.trim() || + ""; + if (!name || name === "@玩家" || name === "用户" || name === "主角") return null; + const description = + text.match(/(?:用户角色\.)?简介[::]\s*([^\n]+)/)?.[1]?.trim() || + text.match(/^简介[::]\s*([^\n]+)/m)?.[1]?.trim() || + ""; + return { name, description }; +} + +export function serializeOpeningPersona(persona: OpeningUserPersona): string { + return JSON.stringify( + { 名字: persona.name, 简介: persona.description }, + null, + 2, + ); +} + +export function readSealedOpeningPersona( + raw: string | null | undefined, +): OpeningUserPersona | null { + return parseUserPersonaField(raw) || parseOpeningUserPersona(raw); +} + function bodyRecord(fragmentBody: unknown): Record | null { if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) { return null; @@ -135,32 +346,58 @@ export function parseOpeningSealPayload( })(); const primary = - asTrimmed(body?.开场白全文) || - asTrimmed(body?.["输出.开场白"]) || - (typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined); + asOpeningBodyText(body?.开场白全文) || + asOpeningBodyText(body?.["输出.开场白"]) || + (typeof fragment?.正文 === "string" + ? asOpeningBodyText(fragment.正文) + : undefined); - const fromList = collectCandidateTexts(body?.开场白候选); + const defaultPersona = parseUserPersonaField(body?.用户角色); + const sharedVariables = collectVariables(body?.开场变量); + const fromList = collectCandidateEntries(body?.开场白候选); const candidates: string[] = []; + const personas: Array = []; + const variableSets: OpeningVariable[][] = []; const seen = new Set(); - const push = (text: string | undefined) => { - if (!text || seen.has(text)) return; + const push = ( + text: string | undefined, + persona: OpeningUserPersona | null, + variables: OpeningVariable[], + ) => { + if (!text) return; + if (seen.has(text)) { + const i = candidates.indexOf(text); + if (i >= 0) { + if (persona && !personas[i]) personas[i] = persona; + if (variables.length && !variableSets[i]?.length) { + variableSets[i] = variables; + } + } + return; + } seen.add(text); candidates.push(text); + personas.push(persona); + variableSets.push(variables); }; - push(primary); - for (const t of fromList) push(t); + push(primary, defaultPersona, sharedVariables); + for (const row of fromList) { + push(row.text, row.persona || defaultPersona, row.variables); + } 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; + const selectedVars = variableSets[idx] ?? []; return { candidates, selectedIndex: idx, selectedText: candidates[idx] ?? candidates[0]!, - variables: collectVariables(body?.开场变量), + variables: selectedVars.length ? selectedVars : sharedVariables, + persona: personas[idx] ?? defaultPersona, }; } diff --git a/src/skills/play-aux.ts b/src/skills/play-aux.ts new file mode 100644 index 0000000..9cd836e --- /dev/null +++ b/src/skills/play-aux.ts @@ -0,0 +1,117 @@ +/** + * 游玩辅助面:表格 / 开场与角色 等 Tab 的视图截面。 + */ +import type { Blackboard } from "../blackboard/blackboard.js"; +import { + isTableCellEditable, + parseTableDoc, +} from "../blackboard/table-cells.js"; +import { + OPENING_PERSONA_CHOICE_TAG, + OPENING_SETUP_ARTIFACT_TAG, + SLOT_OPENING_SELECTED_INDEX, + parseOpeningSealPayload, + splitOpeningDocument, + type OpeningUserPersona, +} from "./opening-seal.js"; +import { CURRENT_VARS_TAG } from "./variable-catalog.js"; +import { readSessionProtagonist } from "../persona/protagonist-bind.js"; +import { presentReadablePlain } from "./present-packet.js"; + +export type PlayAuxTableRow = { + key: string; + value: unknown; + rev: number; + visibility: "visible" | "hidden"; + editable: boolean; + note?: string; +}; + +export type PlayAuxOpening = { + index: number; + text: string; + persona: OpeningUserPersona | null; +}; + +export type PlayAuxView = { + table: { tag: string; rows: PlayAuxTableRow[] } | null; + openings: PlayAuxOpening[]; + selectedOpeningIndex: number; + openingPersona: OpeningUserPersona | null; + openingPersonaChoice: "opening" | "global" | null; +}; + +function selectedOpeningIndex(slots: Record | undefined): number { + const raw = slots?.[SLOT_OPENING_SELECTED_INDEX]; + if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(0, Math.trunc(raw)); + if (typeof raw === "string" && raw.trim()) { + const n = Number(raw); + if (Number.isFinite(n)) return Math.max(0, Math.trunc(n)); + } + return 0; +} + +function collectOpenings(board: Blackboard): PlayAuxOpening[] { + const payload = parseOpeningSealPayload( + board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), + 0, + ); + if (payload?.candidates.length) { + return payload.candidates.map((text, index) => { + const one = parseOpeningSealPayload( + board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG), + index, + ); + return { + index, + text: presentReadablePlain(text) || text, + persona: one?.persona ?? payload.persona, + }; + }); + } + const split = splitOpeningDocument(board.getContentByTag("设计.开场白")); + if (split.text) { + return [{ index: 0, text: split.text, persona: split.persona }]; + } + return []; +} + +export function buildPlayAuxView( + board: Blackboard, + slots?: Record, +): PlayAuxView { + const tableDoc = parseTableDoc(board.getContentByTag(CURRENT_VARS_TAG)); + const table = tableDoc?.rows.length + ? { + tag: CURRENT_VARS_TAG, + rows: tableDoc.rows.map((row) => ({ + key: row.key, + value: row.value, + rev: row.rev, + visibility: row.visibility === "hidden" ? "hidden" : "visible", + editable: isTableCellEditable(row), + ...(row.note ? { note: row.note } : {}), + })), + } + : null; + + const openings = collectOpenings(board); + const idx = selectedOpeningIndex(slots); + const selectedOpeningIndexClamped = openings.length + ? Math.min(idx, openings.length - 1) + : 0; + const sealed = readSessionProtagonist(board); + const choiceRaw = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim(); + const openingPersonaChoice = + choiceRaw === "opening" || choiceRaw === "global" ? choiceRaw : null; + + return { + table, + openings, + selectedOpeningIndex: selectedOpeningIndexClamped, + openingPersona: sealed + ? { name: sealed.name, description: sealed.description } + : openings[selectedOpeningIndexClamped]?.persona ?? null, + openingPersonaChoice, + }; +} diff --git a/src/skills/present-packet.ts b/src/skills/present-packet.ts index 85d03c1..d4ebfcd 100644 --- a/src/skills/present-packet.ts +++ b/src/skills/present-packet.ts @@ -381,9 +381,29 @@ export function isPresentLikeObject(doc: unknown): boolean { const row = doc as Record; if (row.schema === PRESENT_SCHEMA) return true; if (isPresentShellId(row.shell) && row.blocks != null) return true; + if (isPresentShellId(row.shell_id) && row.blocks != null) return true; return false; } +/** 开场/卡片预览:取出 present.v1 主读正文;散文原样返回。 */ +export function presentReadablePlain(raw: string | null | undefined): string { + const text = (raw ?? "").trim(); + if (!text) return ""; + const view = parsePresentPacket(text); + const body = view.packet.blocks.body; + if (typeof body === "string" && body.trim()) return body.trim(); + if (body && typeof body === "object") { + try { + return JSON.stringify(body); + } catch { + return text; + } + } + const header = view.packet.blocks.header; + if (typeof header === "string" && header.trim()) return header.trim(); + return text; +} + /** * 从「设计.正文组成」context-fragment 解析壳适配。 */ diff --git a/src/skills/value-map.ts b/src/skills/value-map.ts index 20789f4..2dc1cd2 100644 --- a/src/skills/value-map.ts +++ b/src/skills/value-map.ts @@ -234,6 +234,29 @@ export function upsertValueMapEntry( return { doc: { schema: VALUE_MAP_SCHEMA, maps } }; } +/** 按 id 移除一条映射;不存在则 error。返回被删条目供调用方清投影 tag */ +export function removeValueMapEntry( + current: ValueMapDoc | null, + idRaw: string, +): { + doc: ValueMapDoc; + error?: string; + removed?: ValueMapEntry; +} { + const empty: ValueMapDoc = { schema: VALUE_MAP_SCHEMA, maps: [] }; + const id = idRaw.trim(); + if (!id) return { doc: current ?? empty, error: "id 不能为空" }; + const maps = current?.maps ?? []; + const idx = maps.findIndex((m) => m.id === id); + if (idx < 0) return { doc: current ?? empty, error: `映射无 id ${id}` }; + const removed = maps[idx]!; + const next = maps.filter((_, i) => i !== idx); + return { + doc: { schema: VALUE_MAP_SCHEMA, maps: next }, + removed, + }; +} + /** 按真值查一档正文;无命中返回 null */ export function lookupValueMapContent( entry: ValueMapEntry, diff --git a/src/skills/variable-catalog.ts b/src/skills/variable-catalog.ts index 85eabc4..cce8100 100644 --- a/src/skills/variable-catalog.ts +++ b/src/skills/variable-catalog.ts @@ -22,6 +22,8 @@ export type VariableCatalogField = { initial: unknown; /** 对用户可见(监控栏等);默认 true */ user_visible: boolean; + /** 游玩表上手改;默认跟可见性走 */ + user_editable?: boolean; note?: string; }; @@ -84,11 +86,19 @@ export function parseVariableCatalog(raw: string | undefined | null): VariableCa f.userVisible === false ? false : true; + const editableRaw = f.user_editable ?? f.可手改 ?? f.userEditable; + const userEditable = + editableRaw === false + ? false + : editableRaw === true + ? true + : userVisible; fields.push({ key, type, initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value), user_visible: userVisible, + user_editable: userEditable, note: asString(f.note) ?? asString(f.备注), }); } @@ -108,6 +118,7 @@ export function serializeVariableCatalog(doc: VariableCatalogDoc): string { type: f.type, initial: f.initial, user_visible: f.user_visible, + user_editable: f.user_editable ?? f.user_visible, ...(f.note ? { note: f.note } : {}), })), }, @@ -121,6 +132,7 @@ export type UpsertVariableInput = { type?: string; initial?: unknown; user_visible?: boolean; + user_editable?: boolean; note?: string; }; @@ -136,6 +148,10 @@ export function upsertVariableField( type, initial: coerceInitial(type, input.initial), user_visible: input.user_visible !== false, + user_editable: + input.user_editable !== undefined + ? input.user_editable + : input.user_visible !== false, note: input.note?.trim() || undefined, }; const fields = [...(current?.fields ?? [])]; @@ -145,17 +161,44 @@ export function upsertVariableField( return { doc: { schema: VARIABLE_CATALOG_SCHEMA, fields } }; } +/** 从目录移除一个真值字段;不存在则 error */ +export function removeVariableField( + current: VariableCatalogDoc | null, + keyRaw: string, +): { doc: VariableCatalogDoc; error?: string; removed?: boolean } { + const empty: VariableCatalogDoc = { + schema: VARIABLE_CATALOG_SCHEMA, + fields: [], + }; + const key = keyRaw.trim(); + if (!key) return { doc: current ?? empty, error: "key 不能为空" }; + const fields = current?.fields ?? []; + const next = fields.filter((f) => f.key !== key); + if (next.length === fields.length) { + return { doc: current ?? empty, error: `目录无字段 ${key}` }; + } + return { + doc: { schema: VARIABLE_CATALOG_SCHEMA, fields: next }, + removed: true, + }; +} + /** 目录 → 表文档(初值);用于种 运行.初始变量 / 变量.当前 */ export function catalogToTableDoc( catalog: VariableCatalogDoc, actor: string, ): TableDoc { const values: Record = {}; - const meta: Record = {}; + const meta: Record< + string, + { visibility?: "visible" | "hidden"; note?: string; editable?: boolean } + > = {}; for (const f of catalog.fields) { values[f.key] = f.initial; + const editable = f.user_editable ?? f.user_visible; meta[f.key] = { visibility: f.user_visible ? "visible" : "hidden", + editable, note: f.note, }; } @@ -192,12 +235,16 @@ export function seedVariablesFromCatalog(params: { // 补缺键,不覆盖已有 const have = new Set(existingCurrent.rows.map((r) => r.key)); const patchValues: Record = {}; - const meta: Record = {}; + const meta: Record< + string, + { visibility?: "visible" | "hidden"; note?: string; editable?: boolean } + > = {}; for (const f of catalog.fields) { if (have.has(f.key)) continue; patchValues[f.key] = f.initial; meta[f.key] = { visibility: f.user_visible ? "visible" : "hidden", + editable: f.user_editable ?? f.user_visible, note: f.note, }; } diff --git a/src/skills/worker-set-view.ts b/src/skills/worker-set-view.ts index 9d54f1b..359c404 100644 --- a/src/skills/worker-set-view.ts +++ b/src/skills/worker-set-view.ts @@ -238,6 +238,8 @@ const DEFAULT_WORKER_CONTRACTS: Record< "世界.拓扑.*", "设计.变量设计与更新规则", "设计.叙事指南与故事推进", + "设计.叙事指南", + "设计.故事推进", "设计.正文组成", "设计.监控栏", ], @@ -265,6 +267,7 @@ const DEFAULT_WORKER_CONTRACTS: Record< "设计.worker集", "设计.叙事指南与故事推进", "设计.叙事指南", + "设计.故事推进", "设计.正文组成", "设计.监控栏", ], diff --git a/src/worker/executor.ts b/src/worker/executor.ts index 51a3620..f1043eb 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -872,6 +872,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise { const driver = createLocalLlmDriver(params.llm); - const label = "主世界层 harness"; + const label = params.label?.trim() + ? `${params.label.trim()} · 主世界层` + : "主世界层 harness"; const result = await driver.run({ system: params.system, diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index 7016d18..10c1517 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -11,6 +11,8 @@ import { formatFlowProgressForAgent, listCallableCatalogModules, formatModuleCatalogForAgent, + formatModuleCatalogForDictate, + catalogModulesForIntake, formatRecipeCatalogForAgent, formatDictateRecipeBrief, formatSelectedRecipeForAgent, @@ -442,6 +444,7 @@ describe("creation-flow", () => { expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe( true, ); + expect(catalog?.modules.some((m) => m.name === "主角设定")).toBe(true); expect(catalog?.modules.some((m) => m.name === "交互范式")).toBe(false); expect(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false); expect( @@ -480,16 +483,52 @@ describe("creation-flow", () => { expect(block).toContain("role=prototype"); expect(block).toContain("编排参数"); expect(block).not.toContain("设计.美学纲领与交互范式"); + expect(block).toContain("- 叙事指南与故事推进:"); + expect(block).not.toMatch(/^- 叙事指南:/m); + expect(block).not.toMatch(/^- 故事推进:/m); + const dictateBlock = formatModuleCatalogForDictate(catalog!); + expect(dictateBlock).toMatch(/^- 叙事指南:/m); + expect(dictateBlock).toMatch(/^- 故事推进:/m); + expect(dictateBlock).not.toMatch(/^- 叙事指南与故事推进/m); + expect( + catalog?.modules.find((m) => m.name === "叙事指南与故事推进")?.intake, + ).toBe("recipe"); + expect(catalog?.modules.find((m) => m.name === "叙事指南")?.intake).toBe( + "dictate", + ); + expect(catalog?.modules.find((m) => m.name === "故事推进")?.intake).toBe( + "dictate", + ); + expect( + catalogModulesForIntake(catalog!, "recipe").some( + (m) => m.name === "叙事指南", + ), + ).toBe(false); + expect( + catalogModulesForIntake(catalog!, "dictate").some( + (m) => m.name === "叙事指南与故事推进", + ), + ).toBe(false); + + const leftover = listCallableCatalogModules({ catalog: catalog! }); + expect(leftover.some((m) => m.name === "叙事指南与故事推进")).toBe(true); + expect(leftover.some((m) => m.name === "叙事指南")).toBe(false); + expect(leftover.some((m) => m.name === "故事推进")).toBe(false); + + const splitFlow = parseCreationFlow(`{ + "steps": [{ "name": "叙事指南", "depends_on": [] }] + }`)!; + expect(validateCreationFlow(splitFlow, catalog).ok).toBe(false); }); it("loads recipe catalog for user director selection UI", async () => { const recipes = await loadRecipeCatalog("dialogue/world-simulator"); - expect(recipes?.recipes.some((r) => r.name === "世界模拟器")).toBe(true); + expect(recipes?.recipes.some((r) => r.name === "回合推演")).toBe(true); expect(recipes?.recipes.some((r) => r.name === "扩写助手")).toBe(true); expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false); const block = formatRecipeCatalogForAgent(recipes!); expect(block).toContain("须由用户手动选择"); - expect(block).toContain("世界模拟器"); + expect(block).toContain("回合推演"); const details = await loadAllRecipeDetails("dialogue/world-simulator"); expect(details.length).toBeGreaterThanOrEqual(2); @@ -519,7 +558,9 @@ describe("creation-flow", () => { expect(dictate?.recipes.some((r) => r.name === "文本生成器")).toBe(true); expect(dictate?.recipes.some((r) => r.name === "交互式长文生成器")).toBe(true); expect(dictate?.recipes.some((r) => r.name === "数据化跑团体验")).toBe(true); - expect(dictate?.recipes[0]?.id).toBe("数据化跑团体验"); + expect(dictate?.recipes.some((r) => r.id === "快穿短局")).toBe(true); + expect(dictate?.recipes.some((r) => r.name === "快穿短局")).toBe(true); + expect(dictate?.recipes[0]?.id).toBe("快穿短局"); const entry = dictate!.recipes.find((r) => r.id === "文本生成器")!; expect(entry.family).toBe("dictate"); const detail = await loadRecipeDetail("dialogue/world-simulator", entry); @@ -540,6 +581,15 @@ describe("creation-flow", () => { expect(formatDictateRecipeBrief(rpgDetail)).toContain("declare_variable"); expect(formatDictateRecipeBrief(rpgDetail)).toContain("何时落盘"); expect(formatDictateRecipeBrief(rpgDetail)).toMatch(/美学纲领|体验核心/); + + const skip = dictate!.recipes.find((r) => r.id === "快穿短局")!; + const skipDetail = await loadRecipeDetail("dialogue/world-simulator", skip); + expect(skipDetail.seed).toBeNull(); + expect(skipDetail.brief).toMatch(/一核|短局|主角/); + expect(skipDetail.core).toMatch(/一核|开场白|黑板/); + expect(skipDetail.when).toMatch(/快穿|一次性|短任务/); + expect(formatDictateRecipeBrief(skipDetail)).toMatch(/舞台骨架|生成规则|拓扑/); + expect(formatDictateRecipeBrief(skipDetail)).toMatch(/回合推演|数据化跑团体验/); }); it("parses selected recipe ref", () => { @@ -649,8 +699,8 @@ recipes: { selectedRecipeRef: "world-simulator" }, ); expect(selected.worker.outputTags).toContain("设计.创作流程"); - expect(selected.promptBody).toContain("【用户已选配方 · 世界模拟器】"); - expect(selected.promptBody).toContain("世界模拟器"); + expect(selected.promptBody).toContain("【用户已选配方 · 回合推演】"); + expect(selected.promptBody).toContain("回合推演"); expect(selected.promptBody).toContain("【能力"); expect(selected.promptBody).toContain("美学纲领与交互范式"); expect(selected.promptBody).toContain("核心思路"); diff --git a/tests/dictate-play-bind.test.ts b/tests/dictate-play-bind.test.ts index f359523..d57b4ba 100644 --- a/tests/dictate-play-bind.test.ts +++ b/tests/dictate-play-bind.test.ts @@ -16,6 +16,8 @@ describe("dictate play-bind", () => { expect(isDictateStyleBindTag("设计.正文组成")).toBe(true); expect(isDictateStyleBindTag("设计.模仿范例")).toBe(true); expect(isDictateStyleBindTag("设计.模仿要点")).toBe(true); + expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true); + expect(isDictateStyleBindTag("设计.故事推进")).toBe(true); expect(isDictateStyleBindTag("用户.需求")).toBe(false); }); @@ -107,7 +109,13 @@ describe("dictate play-bind", () => { metadata: { [DICTATE_ORDER_META_KEY]: -28 }, }); board.write({ - tag: "设计.叙事指南与故事推进", + tag: "设计.叙事指南", + content: "冷硬短句,笔墨落在权力交换。", + source: "dictate", + metadata: { [DICTATE_ORDER_META_KEY]: -20 }, + }); + board.write({ + tag: "设计.故事推进", content: "用户输入用法:大纲扩写。禁止扮演停笔。", source: "dictate", metadata: { [DICTATE_ORDER_META_KEY]: -22 }, @@ -132,11 +140,11 @@ describe("dictate play-bind", () => { const narrRefs = narr!.inserts.map((i) => i.ref); expect(narrRefs).toContain("设计.模仿范例"); expect(narrRefs).toContain("设计.模仿要点"); - expect(narrRefs).toContain("设计.叙事指南与故事推进"); + expect(narrRefs).toContain("设计.叙事指南"); + expect(narrRefs).toContain("设计.故事推进"); expect(narrRefs).not.toContain("用户.需求"); expect(JSON.parse(result.workerSetJson).play_slots.narrator).toBe(true); - // 落档不改写创作期叙事指南 - expect(board.getContentByTag("设计.叙事指南与故事推进")).toBe( + expect(board.getContentByTag("设计.故事推进")).toBe( "用户输入用法:大纲扩写。禁止扮演停笔。", ); }); diff --git a/tests/dictate.test.ts b/tests/dictate.test.ts index 69848ae..5ecdb1a 100644 --- a/tests/dictate.test.ts +++ b/tests/dictate.test.ts @@ -2,16 +2,23 @@ import { describe, expect, it } from "vitest"; import { buildDictateMessages, buildDictateSystemPrompt, + buildDictateInsertFeedbackIndex, defaultDictateOrder, extractDictateDialogue, + formatDictateUserFacingBrief, isAllowedProductTag, isDictateModeValue, + resolveModuleForInsertTag, + runDictateTurn, sortDictateProducts, + resolveRepeatableInsertTag, + lookupDictateInsertFeedback, } from "../src/dictate/index.js"; import { formatModuleCatalogForDictate, loadModuleCatalog, } from "../src/skills/creation-flow.js"; +import type { AgentDriver } from "../src/runtime/driver.js"; describe("dictate context", () => { it("assembles full dialogue + products with almost no filtering", () => { @@ -78,21 +85,37 @@ describe("dictate context", () => { expect(prompt).toContain("设计.开场白"); expect(prompt).toContain("输出.开场白"); expect(prompt).toContain("insert"); + expect(prompt).toContain("delete"); expect(prompt).toContain("declare_variable"); + expect(prompt).toContain("undeclare_variable"); expect(prompt).toContain("declare_map"); + expect(prompt).toContain("remove_map"); + expect(prompt).toContain("同 position 再 insert"); expect(prompt).toContain("数据化跑团体验"); expect(prompt).toMatch(/order/); expect(prompt).toContain("仅允许 toolcall"); expect(prompt).toContain("设计.模仿范例"); expect(prompt).toContain("设计.模仿要点"); - expect(prompt).toContain("设计.叙事指南与故事推进"); + expect(prompt).toContain("设计.故事推进"); + expect(prompt).toContain("设计.叙事指南"); expect(prompt).toContain("大纲扩写"); - expect(prompt).toContain("禁止扮演停笔"); + expect(prompt).toContain("扮演停笔关掉"); expect(prompt).toContain("只挂载这些产物"); expect(prompt).toContain("【能力 · 何时落盘】"); expect(prompt).toContain("设计.美学纲领与交互范式"); - expect(prompt).toContain("每轮通常只推进一刀"); - expect(prompt).toContain("勿因配方名叫数据化就抢跑 D"); + expect(prompt).toContain("同轮可多次 insert"); + expect(prompt).toContain("不抢跑未齐条件的下游"); + expect(prompt).toContain("也不自动开跑 D"); + expect(prompt).toContain("快穿短局"); + expect(prompt).toContain("一核短局"); + expect(prompt).toMatch(/舞台骨架/); + expect(prompt).toMatch(/覆盖 C/); + expect(prompt).toContain("设计.主角设定"); + expect(prompt).toContain("reply_module"); + expect(prompt).toContain("对用户可见回复"); + expect(prompt).toContain("有 N 个 reply_module"); + expect(prompt).toContain("〔可反复〕须拆分"); + expect(prompt).toContain("设计.具体实例#"); }); it("formats module catalog for dictate with land tags and when", async () => { @@ -107,7 +130,13 @@ describe("dictate context", () => { expect(block).toContain("declare_variable"); expect(block).toContain("设计.开场白"); expect(block).toContain("〔落档程序〕"); + expect(block).toContain("设计.生成规则#"); + expect(block).toContain("设计.具体实例#"); + expect(block).toContain("可增殖"); expect(block).not.toContain("role=prototype"); + expect(block).toContain("落盘:insert 「设计.叙事指南」"); + expect(block).toContain("落盘:insert 「设计.故事推进」"); + expect(block).not.toMatch(/^- 叙事指南与故事推进/m); }); it("sorts products by relative order: neg then 0 then pos", () => { @@ -146,4 +175,447 @@ describe("dictate context", () => { expect(body).toContain("order=-10"); expect(body).toContain("order=10"); }); + + it("resolves insert tag to module and builds user-facing brief", async () => { + const catalog = await loadModuleCatalog("dialogue/world-simulator"); + expect(catalog).toBeTruthy(); + const mod = resolveModuleForInsertTag( + "设计.美学纲领与交互范式", + catalog!, + ); + expect(mod?.id).toBe("aesthetics-interaction"); + expect( + resolveModuleForInsertTag("设计.开场白", catalog!)?.id, + ).toBe("opening-setup"); + expect( + resolveModuleForInsertTag("设计.具体实例#lei-ying", catalog!)?.id, + ).toBe("concrete-instances"); + expect( + resolveModuleForInsertTag("设计.生成规则#ex-girlfriend-card", catalog!)?.id, + ).toBe("generation-rules"); + expect(resolveModuleForInsertTag("设计.叙事指南", catalog!)?.id).toBe( + "narrative-guide", + ); + expect(resolveModuleForInsertTag("设计.故事推进", catalog!)?.id).toBe( + "story-progression", + ); + expect(resolveModuleForInsertTag("设计.文风", catalog!)?.id).toBe( + "narrative-guide", + ); + + const index = await buildDictateInsertFeedbackIndex({ + catalog: catalog!, + skillPackRoot: "dialogue/world-simulator", + }); + const brief = index.get("设计.美学纲领与交互范式"); + expect(brief).toBeTruthy(); + expect(brief!).toContain("# 模块 · 美学纲领与交互范式"); + expect(brief!).toContain("回答时请附带本模块"); + expect(brief!).toContain("本模块内容包括"); + expect(brief!).toContain("## 写入用户回复时做什么"); + expect(brief!).toContain("## 探测与追问(本模块口径)"); + expect(brief!).toContain("展示优于提问"); + expect(brief!).toMatch(/自评关注|交互范式|美学纲领/); + + const alias = index.get("设计.开场白"); + expect(alias).toContain("开场白与开场变量"); + }); + + it("splits repeatable insert tags and rejects bare overwrite", async () => { + const families = new Set(["设计.生成规则", "设计.具体实例"]); + const instanceBody = JSON.stringify({ + schema: "context-fragment.v1", + 技能: "具体实例", + 正文: { + rule_id: "ex-girlfriend-card", + batch_id: "lei-ying", + records: [{ 姓名: "雷樱" }], + }, + }); + const first = resolveRepeatableInsertTag({ + tag: "设计.具体实例", + content: instanceBody, + existingTags: [], + repeatableFamilies: families, + }); + expect("tag" in first && first.tag).toBe("设计.具体实例#lei-ying"); + + const second = resolveRepeatableInsertTag({ + tag: "设计.具体实例", + content: JSON.stringify({ + schema: "context-fragment.v1", + 正文: { records: [{ 姓名: "谢明澜" }] }, + }), + existingTags: ["设计.具体实例#lei-ying"], + repeatableFamilies: families, + }); + expect("tag" in second && second.tag).toBe("设计.具体实例#谢明澜"); + + const overwriteBare = resolveRepeatableInsertTag({ + tag: "设计.具体实例", + content: "不是JSON就抽不出槽", + existingTags: ["设计.具体实例#lei-ying"], + repeatableFamilies: families, + }); + expect("error" in overwriteBare).toBe(true); + + const catalog = await loadModuleCatalog("dialogue/world-simulator"); + const index = await buildDictateInsertFeedbackIndex({ + catalog: catalog!, + skillPackRoot: "dialogue/world-simulator", + }); + expect( + lookupDictateInsertFeedback(index, "设计.具体实例#lei-ying"), + ).toContain("具体实例"); + }); + + it("runDictateTurn remaps bare repeatable insert to #slot", async () => { + const products: { tag: string; content: string }[] = []; + const body = JSON.stringify({ + schema: "context-fragment.v1", + 正文: { + batch_id: "lei-ying", + records: [{ 姓名: "雷樱" }], + }, + }); + const driver: AgentDriver = { + async run(input) { + const outcome = await input.handleStep([ + { + id: "c1", + name: "insert", + arguments: JSON.stringify({ + position: "设计.具体实例", + content: body, + }), + }, + ]); + expect(outcome.kind).toBe("continue"); + if (outcome.kind === "continue") { + const payload = JSON.parse(outcome.results[0]!.content) as { + ok?: boolean; + position?: string; + error?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.position).toBe("设计.具体实例#lei-ying"); + } + return { + iterations: 1, + stop: { kind: "text", content: "已落实例" }, + }; + }, + }; + const result = await runDictateTurn({ + llm: {} as never, + dialogue: [{ role: "user", text: "再生成一张雷樱" }], + driver, + repeatableFamilies: ["设计.具体实例", "设计.生成规则"], + handlers: { + listProducts: () => products, + writeProduct: (tag, content) => { + products.push({ tag, content }); + }, + deleteProduct: () => false, + readTag: () => undefined, + writeTag: () => undefined, + deleteTag: () => false, + clearDialogue: () => undefined, + }, + }); + expect(result.wroteTags).toEqual(["设计.具体实例#lei-ying"]); + expect(products.map((p) => p.tag)).toEqual(["设计.具体实例#lei-ying"]); + }); + + it("insert tool result includes reply_module from lookup", async () => { + let capturedSpec: string | undefined; + const driver: AgentDriver = { + async run(input) { + const outcome = await input.handleStep([ + { + id: "c1", + name: "insert", + arguments: JSON.stringify({ + position: "设计.美学纲领与交互范式", + content: "傀儡帝体验核", + }), + }, + ]); + expect(outcome.kind).toBe("continue"); + if (outcome.kind === "continue") { + const payload = JSON.parse(outcome.results[0]!.content) as { + ok?: boolean; + reply_module?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.reply_module).toContain("回答时请附带本模块"); + capturedSpec = payload.reply_module; + } + return { + iterations: 1, + stop: { kind: "text", content: "已落美学;下一刀建议主角名片。" }, + }; + }, + }; + + const products: { tag: string; content: string }[] = []; + const result = await runDictateTurn({ + llm: {} as never, + dialogue: [{ role: "user", text: "傀儡帝恋爱" }], + driver, + handlers: { + listProducts: () => products, + writeProduct: (tag, content) => { + products.push({ tag, content }); + }, + deleteProduct: () => false, + readTag: () => undefined, + writeTag: () => undefined, + deleteTag: () => false, + clearDialogue: () => undefined, + lookupInsertFeedback: () => + formatDictateUserFacingBrief({ + tag: "设计.美学纲领与交互范式", + module: { + id: "aesthetics-interaction", + name: "美学纲领与交互范式", + declaration: "x", + artifact: "设计.美学纲领与交互范式", + }, + promptMd: [ + "```probe", + "缺其中一件:追问 1~2 点", + "```", + "```principles", + "展示优于提问、大胆优于保守", + "```", + "```output", + '{"自评":{"维度":[{"名":"交互范式"},{"名":"美学纲领"}]}}', + "```", + ].join("\n"), + }), + }, + }); + + expect(capturedSpec).toContain("探测与追问"); + expect(result.wroteTags).toContain("设计.美学纲领与交互范式"); + expect(result.reply).toContain("已落美学"); + }); + + it("delete removes a product tag", async () => { + const products = new Map([ + ["设计.开场白", "旧开场"], + ["用户.需求", "雨夜"], + ]); + const driver: AgentDriver = { + async run(input) { + const outcome = await input.handleStep([ + { + id: "d1", + name: "delete", + arguments: JSON.stringify({ + position: "设计.开场白", + reason: "重做开场", + }), + }, + ]); + expect(outcome.kind).toBe("continue"); + if (outcome.kind === "continue") { + const payload = JSON.parse(outcome.results[0]!.content) as { + ok?: boolean; + deleted?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.deleted).toBe("设计.开场白"); + } + return { + iterations: 1, + stop: { kind: "text", content: "已删开场白。" }, + }; + }, + }; + + const result = await runDictateTurn({ + llm: {} as never, + dialogue: [{ role: "user", text: "删掉开场白" }], + driver, + handlers: { + listProducts: () => + [...products.entries()].map(([tag, content]) => ({ tag, content })), + writeProduct: (tag, content) => { + products.set(tag, content); + }, + deleteProduct: (tag) => products.delete(tag), + readTag: () => undefined, + writeTag: () => undefined, + deleteTag: () => false, + clearDialogue: () => undefined, + }, + }); + + expect(products.has("设计.开场白")).toBe(false); + expect(products.has("用户.需求")).toBe(true); + expect(result.deletedTags).toContain("设计.开场白"); + }); + + it("undeclare_variable and remove_map prune catalog/map docs", async () => { + let catalogRaw = + '{"schema":"variable-catalog.v1","fields":[{"key":"好感","type":"number","initial":10,"user_visible":true},{"key":"章节","type":"number","initial":1,"user_visible":true}]}'; + let mapRaw = + '{"schema":"value-map.v1","maps":[{"id":"aff","field":"好感","target_tag":"上下文.角色态度","bands":[{"min":0,"content":"冷"}]}]}'; + let currentRaw = + '{"rows":[{"key":"好感","value":10,"rev":1},{"key":"章节","value":1,"rev":1}]}'; + const projection = new Map([ + ["上下文.角色态度", "冷"], + ]); + const tags = new Map([ + ["设计.变量目录", catalogRaw], + ["设计.变量映射", mapRaw], + ["变量.当前", currentRaw], + ]); + + const driver: AgentDriver = { + async run(input) { + const r1 = await input.handleStep([ + { + id: "u1", + name: "undeclare_variable", + arguments: JSON.stringify({ key: "好感" }), + }, + ]); + expect(r1.kind).toBe("continue"); + const r2 = await input.handleStep([ + { + id: "r1", + name: "remove_map", + arguments: JSON.stringify({ id: "aff" }), + }, + ]); + expect(r2.kind).toBe("continue"); + if (r2.kind === "continue") { + const payload = JSON.parse(r2.results[0]!.content) as { + cleared_target?: string | null; + }; + expect(payload.cleared_target).toBe("上下文.角色态度"); + } + return { + iterations: 2, + stop: { kind: "text", content: "已移除好感与映射。" }, + }; + }, + }; + + const result = await runDictateTurn({ + llm: {} as never, + dialogue: [{ role: "user", text: "去掉好感" }], + driver, + handlers: { + listProducts: () => [], + writeProduct: () => undefined, + deleteProduct: () => false, + readTag: (tag) => tags.get(tag) ?? projection.get(tag), + writeTag: (tag, content) => { + tags.set(tag, content); + if (tag === "设计.变量目录") catalogRaw = content; + if (tag === "设计.变量映射") mapRaw = content; + if (tag === "变量.当前") currentRaw = content; + }, + deleteTag: (tag) => { + const a = tags.delete(tag); + const b = projection.delete(tag); + return a || b; + }, + clearDialogue: () => undefined, + }, + }); + + expect(catalogRaw).toContain("章节"); + expect(catalogRaw).not.toContain('"好感"'); + expect(currentRaw).not.toContain('"好感"'); + expect(tags.has("设计.变量映射")).toBe(false); + expect(projection.has("上下文.角色态度")).toBe(false); + expect(result.deletedTags.some((t) => t.includes("好感"))).toBe(true); + expect(result.deletedTags.some((t) => t.includes("aff"))).toBe(true); + }); + + it("parallel inserts each get a reply_module and multi-module hint", async () => { + const driver: AgentDriver = { + async run(input) { + const outcome = await input.handleStep([ + { + id: "c1", + name: "insert", + arguments: JSON.stringify({ + position: "设计.美学纲领与交互范式", + content: "美学", + }), + }, + { + id: "c2", + name: "insert", + arguments: JSON.stringify({ + position: "设计.主角设定", + content: "主角", + }), + }, + ]); + expect(outcome.kind).toBe("continue"); + if (outcome.kind === "continue") { + const a = JSON.parse(outcome.results[0]!.content) as { + reply_module?: string; + reply_hint?: string; + }; + const b = JSON.parse(outcome.results[1]!.content) as { + reply_module?: string; + reply_hint?: string; + }; + expect(a.reply_module).toContain("美学纲领"); + expect(b.reply_module).toContain("主角设定"); + expect(a.reply_hint).toMatch(/多个 reply_module|本步已带回模块/); + expect(b.reply_hint).toContain("本步已带回模块"); + } + return { + iterations: 1, + stop: { kind: "text", content: "## 模块 · 美学\n\n## 模块 · 主角" }, + }; + }, + }; + + await runDictateTurn({ + llm: {} as never, + dialogue: [{ role: "user", text: "都要" }], + driver, + handlers: { + listProducts: () => [], + writeProduct: () => undefined, + deleteProduct: () => false, + readTag: () => undefined, + writeTag: () => undefined, + deleteTag: () => false, + clearDialogue: () => undefined, + lookupInsertFeedback: (tag) => + tag.includes("美学") + ? formatDictateUserFacingBrief({ + tag, + module: { + id: "aesthetics-interaction", + name: "美学纲领与交互范式", + declaration: "x", + artifact: tag, + }, + promptMd: "```probe\n美学追问\n```", + }) + : formatDictateUserFacingBrief({ + tag, + module: { + id: "protagonist", + name: "主角设定", + declaration: "x", + artifact: tag, + }, + promptMd: "```probe\n主角追问\n```", + }), + }, + }); + }); }); diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts index 27765bf..4ae875f 100644 --- a/tests/display-labels.test.ts +++ b/tests/display-labels.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + displayCreationModeLabel, + displaySkillPackLabel, displayStageLabel, displayWorkerLabel, formatWorkerDisplayTitle, @@ -13,6 +15,13 @@ describe("display-labels", () => { expect(displayStageLabel("play")).toBe("游玩"); }); + it("maps creation intake modes and recipe display names", () => { + expect(displayCreationModeLabel("recipe")).toBe("工序编排"); + expect(displayCreationModeLabel("dictate")).toBe("对话落盘"); + expect(displaySkillPackLabel("world-simulator")).toBe("回合推演"); + expect(displaySkillPackLabel("快穿短局")).toBe("快穿短局"); + }); + it("maps design workers", () => { expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排"); expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤"); @@ -24,9 +33,7 @@ describe("display-labels", () => { it("maps creation unit ids", () => { expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心"); expect(displayWorkerLabel("fixed:interaction")).toBe("技能 · 交互范式"); - expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe( - "技能 · 美学纲领与交互范式", - ); + expect(displayWorkerLabel("fixed:protagonist")).toBe("技能 · 主角设定"); expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述"); }); diff --git a/tests/opening-seal.test.ts b/tests/opening-seal.test.ts index 716c974..221c4f0 100644 --- a/tests/opening-seal.test.ts +++ b/tests/opening-seal.test.ts @@ -5,6 +5,7 @@ import { parseOpeningSealPayload, openingVariablesToTableDoc, reopenCreationFlowRaw, + splitOpeningDocument, } from "../src/skills/opening-seal.js"; const fragment = JSON.stringify({ @@ -63,6 +64,117 @@ describe("opening-seal", () => { ]); }); + it("reads 用户角色 from candidate meta and 正文", () => { + const raw = JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + 正文: { + 开场白全文: "@玩家站在债契前。", + 开场白候选: [ + { + meta: { + 用户角色: { 名字: "沈聿", 简介: "本地放贷人;与这对母女相识但不熟。" }, + }, + 正文: "@玩家站在债契前。", + }, + { + meta: { + 用户角色: { 名字: "阿泽", 简介: "刚穿进来的社畜。" }, + }, + 正文: "门外有人喊 @玩家 的名字。", + }, + ], + 开场变量: [{ 名: "债额", 值: 3 }], + }, + }); + const first = parseOpeningSealPayload(raw, 0); + expect(first?.persona).toEqual({ + name: "沈聿", + description: "本地放贷人;与这对母女相识但不熟。", + }); + const second = parseOpeningSealPayload(raw, 1); + expect(second?.selectedText).toContain("门外"); + expect(second?.persona).toEqual({ + name: "阿泽", + description: "刚穿进来的社畜。", + }); + }); + + it("serializes present.v1 正文 and per-candidate 开场变量", () => { + const packet = { + schema: "present.v1", + shell: "chat_monitor", + blocks: { + monitor: { 债额: "三两" }, + body: "@玩家站在债契前。", + }, + meta: { suggested_actions: ["问债"] }, + }; + const raw = JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + 正文: { + 开场白全文: packet, + 开场白候选: [ + { + meta: { + 用户角色: { 名字: "沈聿", 简介: "放贷人" }, + 开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }], + }, + 正文: packet, + }, + { + meta: { + 用户角色: { 名字: "阿泽", 简介: "社畜" }, + 开场变量: [{ 名: "口粮", 值: 1 }], + }, + 正文: { + schema: "present.v1", + shell: "chat_monitor", + blocks: { body: "门外有人喊 @玩家。" }, + }, + }, + ], + 开场变量: [{ 名: "债额", 值: 3, 依据: "桌上债契" }], + }, + }); + const first = parseOpeningSealPayload(raw, 0); + expect(first?.candidates).toHaveLength(2); + const selected = JSON.parse(first!.selectedText); + expect(selected.schema).toBe("present.v1"); + expect(selected.shell).toBe("chat_monitor"); + expect(selected.blocks.body).toContain("@玩家"); + expect(first?.persona).toEqual({ name: "沈聿", description: "放贷人" }); + expect(first?.variables).toEqual([ + { name: "债额", value: 3, note: "桌上债契" }, + ]); + const second = parseOpeningSealPayload(raw, 1); + expect(second?.persona?.name).toBe("阿泽"); + expect(second?.variables).toEqual([{ name: "口粮", value: 1 }]); + expect(JSON.parse(second!.selectedText).blocks.body).toContain("门外"); + }); + + it("splits YAML frontmatter meta from opening 正文", () => { + const nested = splitOpeningDocument(`--- +用户角色: + 名字: 沈聿 + 简介: 本地放贷人 +--- +清晨,@玩家推开堂屋门。 +`); + expect(nested.persona).toEqual({ name: "沈聿", description: "本地放贷人" }); + expect(nested.text).toBe("清晨,@玩家推开堂屋门。"); + + const dotted = splitOpeningDocument(`--- +用户角色.名字: 阿泽 +用户角色.简介: 刚穿进来的社畜 +--- +门外有人喊 @玩家。 +`); + expect(dotted.persona).toEqual({ name: "阿泽", description: "刚穿进来的社畜" }); + expect(dotted.text).toBe("门外有人喊 @玩家。"); + }); + it("builds table rows from opening variables", () => { const payload = parseOpeningSealPayload(fragment, 0); const doc = openingVariablesToTableDoc(payload!.variables); diff --git a/tests/play-aux.test.ts b/tests/play-aux.test.ts new file mode 100644 index 0000000..7b2d30d --- /dev/null +++ b/tests/play-aux.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { Blackboard } from "../src/blackboard/blackboard.js"; +import { stringifyTableDoc, createTableFromValues } from "../src/blackboard/table-cells.js"; +import { buildPlayAuxView } from "../src/skills/play-aux.js"; +import { SLOT_OPENING_SELECTED_INDEX } from "../src/skills/opening-seal.js"; + +describe("play-aux view", () => { + it("splits visible and hidden table rows and lists opening candidates", () => { + const board = new Blackboard(); + board.write({ + tag: "变量.当前", + content: stringifyTableDoc( + createTableFromValues( + { 口粮: 1, 暗线: "未揭" }, + "worker:opening-setup", + { + 口粮: { visibility: "visible", editable: true }, + 暗线: { visibility: "hidden", editable: false }, + }, + ), + ), + source: "runtime", + }); + board.write({ + tag: "设计.开场白与开场变量", + content: JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + 正文: { + 开场白全文: "@玩家站在债契前。", + 开场白候选: [ + { + meta: { 用户角色: { 名字: "沈聿", 简介: "放贷人" } }, + 正文: "@玩家站在债契前。", + }, + { + meta: { 用户角色: { 名字: "阿泽", 简介: "社畜" } }, + 正文: "门外有人喊 @玩家。", + }, + ], + }, + }), + source: "agent", + }); + const view = buildPlayAuxView(board, { [SLOT_OPENING_SELECTED_INDEX]: 1 }); + expect(view.table?.rows.find((r) => r.key === "口粮")?.editable).toBe(true); + expect(view.table?.rows.find((r) => r.key === "暗线")?.visibility).toBe("hidden"); + expect(view.table?.rows.find((r) => r.key === "暗线")?.editable).toBe(false); + expect(view.openings).toHaveLength(2); + expect(view.selectedOpeningIndex).toBe(1); + expect(view.openings[1]?.persona?.name).toBe("阿泽"); + }); + + it("previews present.v1 opening body instead of raw JSON", () => { + const board = new Blackboard(); + board.write({ + tag: "设计.开场白与开场变量", + content: JSON.stringify({ + schema: "context-fragment.v1", + 技能: "开场白与开场变量", + 正文: { + 开场白全文: { + schema: "present.v1", + shell: "chat_monitor", + blocks: { body: "@玩家站在债契前,堂屋很静。" }, + }, + }, + }), + source: "agent", + }); + const view = buildPlayAuxView(board); + expect(view.openings).toHaveLength(1); + expect(view.openings[0]?.text).toBe("@玩家站在债契前,堂屋很静。"); + expect(view.openings[0]?.text).not.toContain("present.v1"); + }); +}); diff --git a/tests/present-packet.test.ts b/tests/present-packet.test.ts index bd39e9c..4bdf5a1 100644 --- a/tests/present-packet.test.ts +++ b/tests/present-packet.test.ts @@ -5,6 +5,7 @@ import { parseShellAdaptationFromReplyFormat, presentFromPlainText, presentOutputFromFallback, + presentReadablePlain, isPlayFinalVisibleWorker, isPlayGmBodyWorker, isPlayPresentWorker, @@ -146,4 +147,17 @@ describe("play present worker", () => { expect(view.packet.shell).toBe("chat_monitor"); expect(view.packet.blocks.body).toBe("她点了点头。"); }); + + it("presentReadablePlain extracts body from present.v1", () => { + expect( + presentReadablePlain( + JSON.stringify({ + schema: "present.v1", + shell: "spotlight", + blocks: { body: "清晨,@玩家推开门。" }, + }), + ), + ).toBe("清晨,@玩家推开门。"); + expect(presentReadablePlain("门外有人砸门。")).toBe("门外有人砸门。"); + }); }); diff --git a/tests/protagonist-bind.test.ts b/tests/protagonist-bind.test.ts new file mode 100644 index 0000000..2789aa2 --- /dev/null +++ b/tests/protagonist-bind.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Blackboard } from "../src/blackboard/blackboard.js"; + +const MARKDOWN_SPEC = `# 主角设定:债主(@玩家) + +## 名字 +沈聿。母女只知道这个姓和这张脸,叫不出更多。 + +## 身份 +本地放贷人之一,业务不大不小。 + +## 能做什么 +手里握着母亲签下的借据。没有超自然金手指。 + +## 关键背景(用户补充) +许曼的几笔散债被沈聿合并到自己手里。 + +--- + +# 母女档案(NPC) + +## 母亲:许曼(33岁) +不应被当成主角名字。 +`; + +describe("protagonist overlay", () => { + beforeEach(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-protag-"), + ); + }); + + it("parses context-fragment JSON", async () => { + const { parseProtagonistSpec } = await import( + "../src/persona/protagonist-bind.js" + ); + const spec = parseProtagonistSpec( + JSON.stringify({ + schema: "context-fragment.v1", + 技能: "主角设定", + 正文: { + 名字: "林晚", + 背景: "刚被退婚的庶女", + 特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字", 其它: "无" }, + }, + }), + ); + expect(spec?.name).toBe("林晚"); + expect(spec?.cheat).toMatch(/好感/); + }); + + it("parses markdown headings used by dictate", async () => { + const { parseProtagonistSpec, formatProtagonistSummary } = await import( + "../src/persona/protagonist-bind.js" + ); + const spec = parseProtagonistSpec(MARKDOWN_SPEC); + expect(spec?.name).toBe("沈聿"); + expect(spec?.identity).toMatch(/放贷/); + expect(spec?.cheat).toMatch(/借据/); + expect(spec?.background).toMatch(/散债/); + expect(formatProtagonistSummary(spec!).length).toBeLessThan(80); + expect(formatProtagonistSummary(spec!)).not.toMatch(/\*\*/); + }); + + it("overlays play persona from the product without touching global cards", async () => { + const { + PROTAGONIST_TAG, + readSessionProtagonist, + overlayPlayPersona, + } = await import("../src/persona/protagonist-bind.js"); + const { listPersonas, getActivePersona } = await import( + "../src/persona/store.js" + ); + + const board = new Blackboard(); + board.write({ + tag: PROTAGONIST_TAG, + content: JSON.stringify({ + 正文: { + 名字: "林晚", + 背景: "刚被退婚的庶女", + 特殊设定: { 身份: "侯府庶女", 金手指: "看见好感数字" }, + }, + }), + source: "agent", + }); + + const before = listPersonas().map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + })); + const activeId = getActivePersona()?.id; + + const view = readSessionProtagonist(board); + expect(view?.name).toBe("林晚"); + expect(view?.summary).toMatch(/庶女|好感/); + + expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚"); + + const { OPENING_PERSONA_CHOICE_TAG } = await import( + "../src/skills/opening-seal.js" + ); + board.write({ + tag: OPENING_PERSONA_CHOICE_TAG, + content: "opening", + source: "user", + }); + const play = overlayPlayPersona("play", board); + expect(play?.name).toBe("林晚"); + expect(play?.description).toMatch(/庶女/); + expect(overlayPlayPersona("design", board)?.name).not.toBe("林晚"); + + board.write({ + tag: OPENING_PERSONA_CHOICE_TAG, + content: "global", + source: "user", + }); + expect(overlayPlayPersona("play", board)?.name).not.toBe("林晚"); + + expect( + listPersonas().map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + })), + ).toEqual(before); + expect(getActivePersona()?.id).toBe(activeId); + }); + + it("rejects placeholder names", async () => { + const { parseProtagonistSpec } = await import( + "../src/persona/protagonist-bind.js" + ); + expect(parseProtagonistSpec("名字:@玩家\n背景:无")).toBeNull(); + expect(parseProtagonistSpec("名字:主角\n背景:无")).toBeNull(); + expect(parseProtagonistSpec("## 名字\n(待用户给出)\n## 身份\n债主")).toBeNull(); + }); + + it("parses prose fields", async () => { + const { parseProtagonistSpec } = await import( + "../src/persona/protagonist-bind.js" + ); + const spec = parseProtagonistSpec( + "名字:阿泽\n背景:现代社畜刚穿进书里\n金手指:系统面板\n身份:炮灰男配", + ); + expect(spec).toMatchObject({ + name: "阿泽", + background: "现代社畜刚穿进书里", + cheat: "系统面板", + identity: "炮灰男配", + }); + }); + + it("overlays opening meta 用户角色 when the user switches to that preset", async () => { + const { readSessionProtagonist, overlayPlayPersona } = await import( + "../src/persona/protagonist-bind.js" + ); + const { + OPENING_PERSONA_TAG, + OPENING_PERSONA_CHOICE_TAG, + } = await import("../src/skills/opening-seal.js"); + const { listPersonas } = await import("../src/persona/store.js"); + + const board = new Blackboard(); + board.write({ + tag: OPENING_PERSONA_TAG, + content: JSON.stringify({ + 名字: "沈聿", + 简介: "本地放贷人;与这对母女相识但不熟。", + }), + source: "runtime", + }); + board.write({ + tag: OPENING_PERSONA_CHOICE_TAG, + content: "opening", + source: "user", + }); + + const before = listPersonas().map((p) => p.name); + const view = readSessionProtagonist(board); + expect(view?.name).toBe("沈聿"); + expect(view?.description).toMatch(/放贷人/); + + const play = overlayPlayPersona("play", board); + expect(play?.name).toBe("沈聿"); + expect(play?.description).toMatch(/放贷人/); + expect(listPersonas().map((p) => p.name)).toEqual(before); + }); +}); diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts new file mode 100644 index 0000000..f3e8454 --- /dev/null +++ b/tests/request-log.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage, ToolDefinition } from "../src/llm/client.js"; +import { + describeRequestAwaiting, + formatLlmAbortLine, + formatLlmFailLine, + formatLlmFinishLine, + formatLlmSendLine, +} from "../src/llm/request-log.js"; + +const insertTool: ToolDefinition = { + type: "function", + function: { + name: "insert", + description: "write", + parameters: {}, + }, +}; + +describe("describeRequestAwaiting", () => { + it("flags first user turn", () => { + const messages: ChatMessage[] = [ + { role: "system", content: "你是对话落盘创作助手" }, + { role: "user", content: "傀儡皇帝恋爱体验……很长一段用户原话" }, + ]; + expect(describeRequestAwaiting(messages)).toBe("待答用户"); + }); + + it("flags digesting insert without repeating content", () => { + const messages: ChatMessage[] = [ + { role: "system", content: "sys" }, + { role: "user", content: "用户原话" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "c1", + type: "function", + function: { + name: "insert", + arguments: "{\"position\":\"设计.美学纲领与交互范式\",\"content\":\"很长正文\"}", + }, + }, + ], + }, + { role: "tool", content: "{\"ok\":true}", tool_call_id: "c1" }, + ]; + expect(describeRequestAwaiting(messages)).toBe("待消化 写入产物"); + const line = formatLlmSendLine({ + kind: "tools-stream", + caller: "dictate_agent", + label: "对话落盘 第2轮", + messages, + tools: [insertTool], + }); + expect(line).toContain("发送 对话落盘 第2轮 · 工具流式"); + expect(line).toContain("待消化 写入产物"); + expect(line).toContain("条=系统1+用户1+助手1+工具1"); + expect(line).toContain("可调 写入产物"); + expect(line).not.toContain("很长正文"); + expect(line).not.toContain("用户原话"); + }); +}); + +describe("formatLlmSendLine", () => { + it("summarizes a dictate first round", () => { + const line = formatLlmSendLine({ + kind: "tools-stream", + caller: "dictate_agent", + label: "对话落盘 第1轮", + messages: [ + { role: "system", content: "x".repeat(12000) }, + { role: "user", content: "写长文" }, + ], + tools: [insertTool], + }); + expect(line).toBe( + "发送 对话落盘 第1轮 · 工具流式 待答用户 条=系统1+用户1 可调 写入产物 上下文≈1.2万字", + ); + }); +}); + +describe("formatLlmFinishLine", () => { + it("reports completion without dumping body", () => { + const line = formatLlmFinishLine( + { + kind: "tools-stream", + caller: "dictate_agent", + label: "对话落盘 第1轮", + messages: [], + }, + 7306, + { + content: "短确认", + reasoning: "", + model: "deepseek-v4-pro", + toolCalls: [{ id: "1", name: "insert", arguments: "SECRET_BODY" }], + usage: { promptTokens: 14385, completionTokens: 413, totalTokens: 14798 }, + }, + ); + expect(line).toContain("完成 对话落盘 第1轮 · 工具流式"); + expect(line).toContain("7306毫秒"); + expect(line).toContain("模型=deepseek-v4-pro"); + expect(line).toContain("调用=写入产物"); + expect(line).toContain("用量=14385+413"); + expect(line).not.toContain("SECRET_BODY"); + expect(line).not.toContain("短确认"); + }); +}); + +describe("abort and fail lines", () => { + it("marks user abort separately from failure", () => { + expect( + formatLlmAbortLine( + { kind: "tools-stream", caller: "dictate_agent", label: "对话落盘 第1轮" }, + 2100, + ), + ).toBe("中断 对话落盘 第1轮 · 工具流式 2100毫秒"); + expect( + formatLlmFailLine( + { kind: "stream", caller: "worker:design-step", label: "设计监控栏" }, + 10787, + new Error("fetch failed"), + ), + ).toBe("失败 执行单元 design-step · 设计监控栏 · 流式 10787毫秒 fetch failed"); + }); +}); diff --git a/tests/table-cells.test.ts b/tests/table-cells.test.ts index 1ee73c2..ae843c0 100644 --- a/tests/table-cells.test.ts +++ b/tests/table-cells.test.ts @@ -48,4 +48,32 @@ describe("table-cells", () => { expect(skipped[0]?.reason).toContain("rev-conflict"); expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10); }); + + it("user patches require matching rev and skip hidden cells", async () => { + const { patchUserTableCells } = await import("../src/blackboard/table-cells.js"); + const current = createTableFromValues( + { 口粮: 1, 暗线: "未揭" }, + "worker:a", + { 口粮: { visibility: "visible" }, 暗线: { visibility: "hidden" } }, + ); + current.rows.find((r) => r.key === "口粮")!.rev = 2; + const { applied, skipped, doc } = patchUserTableCells(current, [ + { key: "口粮", value: 3, expectedRev: 1 }, + { key: "暗线", value: "揭了", expectedRev: 1 }, + ]); + expect(applied).toEqual([]); + expect(skipped.some((s) => s.key === "口粮" && s.reason.includes("rev"))).toBe( + true, + ); + expect(skipped.some((s) => s.key === "暗线" && s.reason === "not-editable")).toBe( + true, + ); + const ok = patchUserTableCells(current, [ + { key: "口粮", value: 3, expectedRev: 2 }, + ]); + expect(ok.applied).toEqual(["口粮"]); + expect(ok.doc.rows.find((r) => r.key === "口粮")?.value).toBe(3); + expect(ok.doc.rows.find((r) => r.key === "口粮")?.rev).toBe(3); + expect(doc.rows.find((r) => r.key === "口粮")?.value).toBe(1); + }); }); diff --git a/tests/value-map.test.ts b/tests/value-map.test.ts index ccacb8c..a74f9f0 100644 --- a/tests/value-map.test.ts +++ b/tests/value-map.test.ts @@ -6,6 +6,7 @@ import { reprojectValueMaps, serializeValueMapDoc, upsertValueMapEntry, + removeValueMapEntry, VALUE_MAP_TAG, } from "../src/skills/value-map.js"; import { @@ -13,6 +14,7 @@ import { seedVariablesFromCatalog, serializeVariableCatalog, upsertVariableField, + removeVariableField, VARIABLE_CATALOG_TAG, } from "../src/skills/variable-catalog.js"; import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js"; @@ -47,6 +49,15 @@ describe("variable-catalog", () => { expect(seeded.current).toContain("好感"); expect(seeded.initial).toContain("旧港"); }); + + it("removes a catalog field", () => { + const a = upsertVariableField(null, { key: "好感", type: "number", initial: 1 }); + const b = upsertVariableField(a.doc, { key: "章节", type: "number", initial: 1 }); + const { doc, error } = removeVariableField(b.doc, "好感"); + expect(error).toBeUndefined(); + expect(doc.fields.map((f) => f.key)).toEqual(["章节"]); + expect(removeVariableField(doc, "好感").error).toMatch(/无字段/); + }); }); describe("value-map", () => { @@ -142,6 +153,20 @@ describe("value-map", () => { expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章"); expect(lookupValueMapContent(parsed!.maps[0]!, 9)).toBe("未定"); }); + + it("removes map by id", () => { + const { doc: withMap } = upsertValueMapEntry(null, { + id: "aff", + field: "好感", + target_tag: "上下文.角色态度", + bands: [{ min: 0, content: "冷" }], + }); + const { doc, removed, error } = removeValueMapEntry(withMap, "aff"); + expect(error).toBeUndefined(); + expect(removed?.id).toBe("aff"); + expect(doc.maps).toHaveLength(0); + expect(removeValueMapEntry(doc, "aff").error).toMatch(/无 id/); + }); }); describe("catalog tag constants", () => { diff --git a/web/agent-ui.js b/web/agent-ui.js index fcda820..6f0098f 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -1,5 +1,6 @@ import { renderIntakePanel } from "./intake-ui.js"; import { tryParseJsonDoc } from "./json-doc.js"; +import { formatBodyHtml } from "./markdown.js"; import { getActiveQuestions, getModuleOpeningPrompt, @@ -7,8 +8,8 @@ import { isQuestionCardDismissed, renderQuestionsCard, } from "./questions-ui.js"; -import { displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; -import { formatBodyHtml } from "./markdown.js"; +import { displayCreationModeLabel, displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; +import { fillPlayAux, hidePlayAuxTabs } from "./play-aux.js"; import { PRESENT_SHELL_IDS, parsePresentDoc, @@ -47,7 +48,7 @@ const MSG_LABEL = { orchestrator_thinking: "编排器 · 思考", orchestrator_prompt: "编排器", orchestrator_assessment: "编排器 · 内容评价", - dictate_reply: "Boss直聘", + dictate_reply: "对话落盘", worker_running: "执行单元", worker_output: "执行单元", worker_questions: "提问", @@ -62,6 +63,16 @@ function esc(s) { .replace(/>/g, ">"); } +/** 正文 HTML;缺导入或渲染失败时退回纯文本,避免整页对话空白 */ +function safeBodyHtml(text) { + try { + if (typeof formatBodyHtml === "function") return formatBodyHtml(text); + } catch { + /* fall through */ + } + return esc(text).replace(/\n/g, "
"); +} + function escAttr(s) { return esc(s).replace(/"/g, """); } @@ -314,6 +325,367 @@ const msgMenuState = { handlers: null, }; +const MSG_PEEK_SIZE_KEY = "wa.msg-peek.size"; +/** 拖出后仍须留在视口内的侧/底条带(px) */ +const MSG_PEEK_KEEP_VISIBLE = 48; +/** 标题栏必须可见的顶边距(px)——保证可拖 */ +const MSG_PEEK_TOP_MARGIN = 8; +const MSG_PEEK_MIN_W = 280; +const MSG_PEEK_MIN_H = 180; + +const msgPeekState = { + wired: false, + dragging: false, + resizing: false, + offsetX: 0, + offsetY: 0, + resizeStartX: 0, + resizeStartY: 0, + resizeStartW: 0, + resizeStartH: 0, + lastLeft: null, + lastTop: null, +}; + +function hideMsgPeek() { + const panel = document.getElementById("msg-peek-panel"); + if (!panel || panel.hidden) return; + panel.hidden = true; + panel.classList.remove("is-dragging", "is-resizing"); + msgPeekState.dragging = false; + msgPeekState.resizing = false; + const body = document.getElementById("msg-peek-body"); + if (body) body.innerHTML = ""; +} + +function loadMsgPeekSize() { + try { + const raw = localStorage.getItem(MSG_PEEK_SIZE_KEY); + if (!raw) return null; + const o = JSON.parse(raw); + const width = Number(o?.width); + const height = Number(o?.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return { width, height }; + } catch { + return null; + } +} + +function saveMsgPeekSize(width, height) { + try { + localStorage.setItem( + MSG_PEEK_SIZE_KEY, + JSON.stringify({ + width: Math.round(width), + height: Math.round(height), + }), + ); + } catch { + /* quota / private mode */ + } +} + +function defaultMsgPeekSize() { + return { + width: Math.min(680, Math.max(MSG_PEEK_MIN_W, window.innerWidth * 0.92)), + height: Math.min(760, Math.max(MSG_PEEK_MIN_H, window.innerHeight * 0.82)), + }; +} + +function clampMsgPeekSize(width, height) { + const maxW = Math.max(MSG_PEEK_MIN_W, window.innerWidth - 16); + const maxH = Math.max(MSG_PEEK_MIN_H, window.innerHeight - MSG_PEEK_TOP_MARGIN * 2); + return { + width: Math.min(Math.max(MSG_PEEK_MIN_W, width), maxW), + height: Math.min(Math.max(MSG_PEEK_MIN_H, height), maxH), + }; +} + +function applyMsgPeekSize(panel) { + const saved = loadMsgPeekSize(); + const base = saved ?? defaultMsgPeekSize(); + const size = clampMsgPeekSize(base.width, base.height); + panel.style.width = `${size.width}px`; + panel.style.height = `${size.height}px`; + panel.style.maxHeight = "none"; + return size; +} + +/** + * 顶边:标题栏始终留在视口内(可拖)。 + * 左右/底:允许部分拖出,但至少 KEEP 像素相交。 + */ +function clampMsgPeekPosition(panel, left, top) { + const rect = panel.getBoundingClientRect(); + const w = rect.width || panel.offsetWidth; + const h = rect.height || panel.offsetHeight; + const keep = MSG_PEEK_KEEP_VISIBLE; + const minLeft = keep - w; + const maxLeft = window.innerWidth - keep; + const minTop = MSG_PEEK_TOP_MARGIN; + const maxTop = Math.max(minTop, window.innerHeight - keep); + return { + left: Math.min(Math.max(minLeft, left), maxLeft), + top: Math.min(Math.max(minTop, top), maxTop), + }; +} + +function isMsgPeekFullyOffscreen(panel) { + const r = panel.getBoundingClientRect(); + return ( + r.right <= 0 || + r.left >= window.innerWidth || + r.bottom <= 0 || + r.top >= window.innerHeight + ); +} + +/** 每次打开:记忆尺寸 + 屏幕居中(标题栏保证可见) */ +function placeMsgPeek(panel) { + const size = applyMsgPeekSize(panel); + panel.style.visibility = "hidden"; + panel.hidden = false; + const centerLeft = (window.innerWidth - size.width) / 2; + const centerTop = (window.innerHeight - size.height) / 2; + const pos = clampMsgPeekPosition(panel, centerLeft, centerTop); + panel.style.left = `${pos.left}px`; + panel.style.top = `${pos.top}px`; + msgPeekState.lastLeft = pos.left; + msgPeekState.lastTop = pos.top; + panel.style.visibility = ""; +} + +function wireMsgPeekPanel() { + if (msgPeekState.wired) return; + const panel = document.getElementById("msg-peek-panel"); + if (!panel) return; + msgPeekState.wired = true; + + document.getElementById("msg-peek-close")?.addEventListener("click", () => { + hideMsgPeek(); + }); + + const head = panel.querySelector("[data-peek-drag]"); + head?.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + if (e.target.closest(".msg-peek-close")) return; + if (msgPeekState.resizing) return; + const rect = panel.getBoundingClientRect(); + msgPeekState.dragging = true; + msgPeekState.offsetX = e.clientX - rect.left; + msgPeekState.offsetY = e.clientY - rect.top; + panel.classList.add("is-dragging"); + head.setPointerCapture?.(e.pointerId); + e.preventDefault(); + }); + + const resizeHandle = panel.querySelector("[data-peek-resize]"); + resizeHandle?.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + if (msgPeekState.dragging) return; + const rect = panel.getBoundingClientRect(); + msgPeekState.resizing = true; + msgPeekState.resizeStartX = e.clientX; + msgPeekState.resizeStartY = e.clientY; + msgPeekState.resizeStartW = rect.width; + msgPeekState.resizeStartH = rect.height; + panel.classList.add("is-resizing"); + resizeHandle.setPointerCapture?.(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + }); + + const onMove = (e) => { + if (msgPeekState.dragging) { + const next = clampMsgPeekPosition( + panel, + e.clientX - msgPeekState.offsetX, + e.clientY - msgPeekState.offsetY, + ); + panel.style.left = `${next.left}px`; + panel.style.top = `${next.top}px`; + msgPeekState.lastLeft = next.left; + msgPeekState.lastTop = next.top; + return; + } + if (msgPeekState.resizing) { + const dw = e.clientX - msgPeekState.resizeStartX; + const dh = e.clientY - msgPeekState.resizeStartY; + const size = clampMsgPeekSize( + msgPeekState.resizeStartW + dw, + msgPeekState.resizeStartH + dh, + ); + panel.style.width = `${size.width}px`; + panel.style.height = `${size.height}px`; + } + }; + const onUp = (e) => { + if (msgPeekState.dragging) { + msgPeekState.dragging = false; + panel.classList.remove("is-dragging"); + head?.releasePointerCapture?.(e.pointerId); + if (!panel.hidden && isMsgPeekFullyOffscreen(panel)) { + hideMsgPeek(); + return; + } + } + if (msgPeekState.resizing) { + msgPeekState.resizing = false; + panel.classList.remove("is-resizing"); + resizeHandle?.releasePointerCapture?.(e.pointerId); + if (!panel.hidden) { + const rect = panel.getBoundingClientRect(); + const size = clampMsgPeekSize(rect.width, rect.height); + panel.style.width = `${size.width}px`; + panel.style.height = `${size.height}px`; + saveMsgPeekSize(size.width, size.height); + const pos = clampMsgPeekPosition( + panel, + Number.parseFloat(panel.style.left) || rect.left, + Number.parseFloat(panel.style.top) || rect.top, + ); + panel.style.left = `${pos.left}px`; + panel.style.top = `${pos.top}px`; + msgPeekState.lastLeft = pos.left; + msgPeekState.lastTop = pos.top; + } + } + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); +} + +function openContentPeek(payload) { + wireMsgPeekPanel(); + const panel = document.getElementById("msg-peek-panel"); + const titleEl = document.getElementById("msg-peek-title"); + const subEl = document.getElementById("msg-peek-subtitle"); + const bodyEl = document.getElementById("msg-peek-body"); + if (!panel || !titleEl || !bodyEl) return; + + titleEl.textContent = payload?.title || "完整内容"; + if (subEl) { + const subtitle = String(payload?.subtitle || "").trim(); + if (subtitle) { + subEl.hidden = false; + subEl.textContent = subtitle; + } else { + subEl.hidden = true; + subEl.textContent = ""; + } + } + bodyEl.innerHTML = payload?.html || `

(无正文)

`; + + placeMsgPeek(panel); + bodyEl.scrollTop = 0; + document.getElementById("msg-peek-close")?.focus?.(); +} + +function formatDictatePeekHtml(content) { + const trimmed = String(content ?? "").trim(); + if (!trimmed) return `

(无正文)

`; + // 与侧栏同一路径:勿走 formatArtifactBodyHtml(## 分段会丢掉前置 JSON/正文) + return `
${safeBodyHtml(trimmed)}
`; +} + +const dictateProductContentByEl = new WeakMap(); + +function rememberDictateProductContent(el, content) { + if (!el) return; + const text = String(content ?? ""); + dictateProductContentByEl.set(el, text); + // 短文仍写 dataset,便于调试;长文只放 WeakMap,避免属性截断 + if (text.length <= 4000) el.dataset.originalText = text; + else delete el.dataset.originalText; +} + +function readDictateProductContent(card) { + if (!card) return ""; + if (dictateProductContentByEl.has(card)) { + return dictateProductContentByEl.get(card) ?? ""; + } + return card.dataset.originalText || ""; +} + +const dictateProductMenuState = { + card: null, + wired: false, +}; + +function hideDictateProductMenu() { + const menu = document.getElementById("dictate-product-menu"); + if (menu) menu.hidden = true; + dictateProductMenuState.card = null; +} + +function showDictateProductMenu(card, x, y) { + const menu = document.getElementById("dictate-product-menu"); + if (!menu || !card) return; + hideMsgMenu(); + hideDictateProductMenu(); + dictateProductMenuState.card = card; + menu.hidden = false; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`; + } +} + +function wireDictateProductMenu() { + if (dictateProductMenuState.wired) return; + const menu = document.getElementById("dictate-product-menu"); + if (!menu) return; + dictateProductMenuState.wired = true; + menu.addEventListener("click", async (e) => { + const action = e.target + .closest("[data-dictate-product-action]") + ?.getAttribute("data-dictate-product-action"); + const card = dictateProductMenuState.card; + hideDictateProductMenu(); + if (!action || !card) return; + const content = readDictateProductContent(card); + const tagEl = card.querySelector(".dictate-product-tag"); + const title = + tagEl?.getAttribute("title")?.trim() || + tagEl?.textContent?.trim() || + "产物"; + if (action === "peek") { + openContentPeek({ + title, + html: formatDictatePeekHtml(content), + }); + return; + } + if (action === "copy") { + try { + await navigator.clipboard.writeText(content); + } catch { + /* ignore */ + } + } + }); +} + +function wireDictateProductContextMenu(root) { + if (!root || root.dataset.dictateProductMenuWired) return; + root.dataset.dictateProductMenuWired = "1"; + wireDictateProductMenu(); + root.addEventListener("contextmenu", (e) => { + const card = e.target.closest(".dictate-product"); + if (!card || !root.contains(card)) return; + e.preventDefault(); + e.stopPropagation(); + showDictateProductMenu(card, e.clientX, e.clientY); + }); +} + function showMsgMenu(card, x, y) { const menu = document.getElementById("msg-action-menu"); if (!menu || !card) return; @@ -510,7 +882,7 @@ function cancelInlineEdit(card) { card.querySelector(".msg-foot")?.removeAttribute("hidden"); const editing = card.querySelector(".msg-body-editing"); if (editing) { - editing.outerHTML = `
${formatBodyHtml(original)}
`; + editing.outerHTML = `
${safeBodyHtml(original)}
`; } } @@ -777,7 +1149,7 @@ function isWorkspaceNav() { function syncExpandLabel() { const label = document.getElementById("rail-expand-label"); - if (label) label.textContent = activeRailTab === "log" ? "志" : "书"; + if (label) label.textContent = activeRailTab === "log" ? "调度" : "作品"; } export function setRailTab(tab, { expand = false } = {}) { @@ -862,7 +1234,11 @@ export function resetRailChrome() { railUserExpanded = true; coordRailCollapsed = true; document.body.dataset.railCanCollapse = "0"; - document.body.classList.remove("rail-collapsed", "coord-rail-collapsed"); + document.body.classList.remove( + "rail-collapsed", + "coord-rail-collapsed", + "play-workspace", + ); const rail = document.getElementById("coord-rail"); if (rail) { rail.hidden = true; @@ -1418,10 +1794,21 @@ function splitTaggedArtifactSections(text) { hits.push({ title: m[1].trim(), index: m.index, headerEnd: m.index + m[0].length }); } if (!hits.length) return [{ title: "", content: trimmed }]; - return hits.map((h, i) => { + const sections = []; + // 保留首个 ## 之前的前置正文(常见:JSON / 散文 + ## 小节) + if (hits[0].index > 0) { + const preamble = trimmed.slice(0, hits[0].index).trim(); + if (preamble) sections.push({ title: "", content: preamble }); + } + for (let i = 0; i < hits.length; i++) { + const h = hits[i]; const end = i + 1 < hits.length ? hits[i + 1].index : trimmed.length; - return { title: h.title, content: trimmed.slice(h.headerEnd, end).trim() }; - }); + sections.push({ + title: h.title, + content: trimmed.slice(h.headerEnd, end).trim(), + }); + } + return sections; } const FRAGMENT_HEADER_KEYS = new Set([ @@ -1638,24 +2025,25 @@ function renderPlainCompactCard(title, content, opts = {}) { return ""; } const preview = clampPreviewText(content, 160); + const looksJson = /^\s*[{\[]/.test(String(content || "")); if (opts.flat) { + const body = looksJson + ? `

未能解析为可展示产物。

+
${esc(content)}
` + : `
${safeBodyHtml(content)}
`; return `
-
-

未能解析为可展示产物。

-
${esc(content)}
-
+
${body}
`; } const openAttr = opts.open ? " open" : ""; - const looksJson = /^\s*[{\[]/.test(String(content || "")); const body = looksJson ? `

未能解析为 JSON,以下为原文(可展开原始块排查)。

${esc(content)}
` - : `
${esc(content)}
`; + : `
${safeBodyHtml(content)}
`; return `
@@ -1987,37 +2375,126 @@ function renderContextFragmentHtml(doc, opts = {}) { } /** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */ -function collectOpeningCandidateTexts(body) { - const texts = []; - const push = (value) => { - const text = String(value ?? "").trim(); - if (text && !texts.includes(text)) texts.push(text); +function openingPersonaFrom(row, fallback) { + const p = row?.用户角色 || row?.meta?.用户角色 || fallback || null; + if (!p || typeof p !== "object") return null; + const name = String(p.名字 || p.name || "").trim(); + if (!name) return null; + return { + 名字: name, + 简介: String(p.简介 || p.description || "").trim(), }; - push(body.开场白全文); +} + +function openingTextFrom(raw) { + if (typeof raw === "string") return raw.trim(); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + if ( + raw.schema === "present.v1" || + raw.blocks != null || + raw.shell || + raw.shell_id + ) { + try { + return JSON.stringify(raw); + } catch { + return ""; + } + } + return String(raw.正文 || raw.全文 || raw.text || raw.开场白 || raw.内容 || "").trim(); + } + return ""; +} + +function openingVariablesFrom(row) { + const list = Array.isArray(row?.开场变量) + ? row.开场变量 + : Array.isArray(row?.meta?.开场变量) + ? row.meta.开场变量 + : []; + return list.filter((item) => item && typeof item === "object"); +} + +function collectOpeningCandidates(body) { + const fallback = openingPersonaFrom(body, null); + const sharedVars = openingVariablesFrom(body); + const rows = []; + const seen = new Set(); + const push = (text, persona, variables) => { + const value = openingTextFrom(text); + if (!value || seen.has(value)) return; + seen.add(value); + rows.push({ + text: value, + persona: persona || fallback, + variables: variables?.length ? variables : sharedVars, + }); + }; + push(body.开场白全文, fallback, sharedVars); if (Array.isArray(body.开场白候选)) { for (const item of body.开场白候选) { - if (typeof item === "string") push(item); + if (typeof item === "string") push(item, fallback, sharedVars); else if (item && typeof item === "object") { - push(item.全文 || item.text || item.开场白 || item.内容); + push( + item.正文 || item.全文 || item.text || item.开场白 || item.内容, + openingPersonaFrom(item, fallback), + openingVariablesFrom(item), + ); } } } - return texts; + return rows; +} + +function renderOpeningPersonaHtml(persona) { + if (!persona?.名字) return ""; + return `

用户角色

${esc( + String(persona.名字), + )}${ + persona.简介 ? ` · ${esc(String(persona.简介))}` : "" + }

`; +} + +function renderOpeningVarsHtml(vars) { + if (!Array.isArray(vars) || !vars.length) return ""; + return `

开场变量

    ${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("")}
`; +} + +function openingPresentPacket(text) { + const trimmed = String(text || "").trim(); + if (!trimmed) return presentFromPlain("", "prose"); + if (trimmed.startsWith("{")) { + const parsed = tryParseJsonDoc(trimmed); + const view = parsed ? parsePresentDoc(parsed) : null; + if (view) return view.packet; + } + return presentFromPlain(trimmed, "prose"); } function renderOpeningSetupBodyHtml(body) { if (!body || typeof body !== "object" || Array.isArray(body)) return ""; - const candidates = collectOpeningCandidateTexts(body); + const candidates = collectOpeningCandidates(body); if (!candidates.length) return ""; const total = candidates.length; + const anyCandidateVars = candidates.some((row) => row.variables?.length); const slides = candidates - .map((text, i) => { + .map((row, i) => { const hidden = i === 0 ? "" : " hidden"; const selected = i === 0 ? " is-selected" : ""; return `
${renderPresentShellHtml( - presentFromPlain(text, "prose"), + openingPresentPacket(row.text), esc, - )}
`; + )}${renderOpeningPersonaHtml(row.persona)}${renderOpeningVarsHtml(row.variables)}`; }) .join(""); const nav = @@ -2028,20 +2505,10 @@ function renderOpeningSetupBodyHtml(body) {
` : ""; - 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 varsHtml = + !anyCandidateVars && Array.isArray(body.开场变量) && body.开场变量.length + ? renderOpeningVarsHtml(body.开场变量) + : ""; const space = body.用户可行动空间 ? `

可行动空间

${renderProseHtml( body.用户可行动空间, @@ -2117,7 +2584,8 @@ function renderSpecialtyBodyHtml(body, skill) { if ( body.风格与写法 != null || body.推进与决策 != null || - skillName.includes("叙事指南") + skillName.includes("叙事指南") || + skillName.includes("故事推进") ) { const html = renderNarrativeBodyHtml(body); if (html) return html; @@ -4925,7 +5393,7 @@ function renderSpeakWorkspace(view) { wr.message || "描述本轮行动、对话或想说的话;由游玩管线推进。"; } else if (view.creationMode === "dictate") { - title = "Boss 直聘"; + title = displayCreationModeLabel("dictate"); hint = view.hints?.[0] || wr.message || @@ -4976,7 +5444,7 @@ function renderSpeakWorkspace(view) { ? workspaceViewSwitchHtml("flow") : `${ view.creationMode === "dictate" - ? "Boss直聘" + ? displayCreationModeLabel("dictate") : canOfferSaveProduct(view) ? "落档" : "说话" @@ -4993,34 +5461,79 @@ function renderSpeakWorkspace(view) {
`; } -/** 转述式:产物列表(右侧栏) */ +/** 转述式:产物列表(右侧栏)— 默认折叠,点开再渲染正文 */ function renderDictateProductsList(view) { const items = Array.isArray(view.dictateProducts) ? view.dictateProducts : []; if (!items.length) { - return `

尚无写入。Agent 落盘产物后会出现在这里。

`; + return `
+

尚无产物

+

Agent 落盘后会出现在这里。点击行展开,右键可查看全文或复制。

+
`; } - return `
${items + return `
${items .map((p) => { const tag = String(p.tag ?? "").trim(); const content = String(p.content ?? ""); const preview = content.replace(/\s+/g, " ").trim().slice(0, 72); - return `
- - ${esc(tag)}${ - p.order != null && Number.isFinite(Number(p.order)) - ? ` · ${Number(p.order)}` - : "" - } - ${esc(preview)}${ - content.length > 72 ? "…" : "" - } + const order = + p.order != null && Number.isFinite(Number(p.order)) + ? Number(p.order) + : null; + const shortTag = tag + .replace(/^设计\./, "") + .replace(/^用户\./, "") + .replace(/#/g, " · "); + return `
+ + + ${esc(shortTag || tag || "未命名")} + ${ + order != null + ? `${order}` + : "" + } + + + ${ + preview + ? `${esc(preview)}${ + content.length > 72 ? "…" : "" + }` + : `(空)` + } -
${formatBodyHtml(content)}
+
`; }) .join("")}
`; } +function hydrateDictateProductBody(card) { + if (!card) return; + const body = card.querySelector(".dictate-product-body"); + if (!body || body.dataset.pending !== "1") return; + const content = readDictateProductContent(card); + body.innerHTML = content.trim() + ? safeBodyHtml(content) + : `

(无正文)

`; + delete body.dataset.pending; +} + +function wireDictateProductAccordion(root) { + if (!root || root.dataset.dictateProductAccordionWired) return; + root.dataset.dictateProductAccordionWired = "1"; + root.addEventListener("toggle", (e) => { + const card = e.target; + if (!(card instanceof HTMLDetailsElement)) return; + if (!card.classList.contains("dictate-product--rail")) return; + if (!card.open) return; + hydrateDictateProductBody(card); + root.querySelectorAll("details.dictate-product--rail[open]").forEach((other) => { + if (other !== card) other.open = false; + }); + }, true); +} + function fillCoordRailProducts(view) { const coordRail = document.getElementById("coord-rail"); const drawerBody = document.getElementById("coord-drawer-body"); @@ -5032,7 +5545,15 @@ function fillCoordRailProducts(view) { coordRailCollapsed = false; } coordRail.hidden = false; - if (drawerBody) drawerBody.innerHTML = renderDictateProductsList(view); + if (drawerBody) { + drawerBody.innerHTML = renderDictateProductsList(view); + const cards = drawerBody.querySelectorAll(".dictate-product"); + cards.forEach((el, i) => { + rememberDictateProductContent(el, items[i]?.content ?? ""); + }); + wireDictateProductContextMenu(drawerBody); + wireDictateProductAccordion(drawerBody); + } if (drawerCount) { drawerCount.textContent = String(items.length); drawerCount.title = items.length @@ -5108,6 +5629,10 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) { sourceMessage: resolveReviewSourceMessage(view), leaveStepHtml: leaveStepControlHtml(view), }) + askSlot; + const reviewEl = stage.querySelector(".workspace-review"); + if (reviewEl && view.reviewArtifact?.body) { + reviewEl.dataset.originalText = String(view.reviewArtifact.body); + } wireMessageFeedActions(stage, handlers); wireMessageContextMenu(stage, handlers); wireCreationFlowGraph(stage, handlers); @@ -5176,6 +5701,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (drawerBody) drawerBody.innerHTML = ""; if (drawerCount) drawerCount.textContent = ""; } + document.body.classList.toggle("play-workspace", false); + hidePlayAuxTabs(); // 默认把询问卡停回主列底部槽位 if (panelFeed) ensureQuestionsHostIn(panelFeed); @@ -5195,36 +5722,41 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (!stage) return; - // 转述:主柱对话流;右侧已持久化;stage 仅一行条 + // 转述:对话画在 stage 内(design-workspace 会藏掉 #message-feed) if (surface === "dictate") { stage.hidden = false; const recipeName = view.selectedRecipe?.name ? esc(view.selectedRecipe.name) : ""; - stage.innerHTML = recipeName - ? `
Boss直聘配方 · ${recipeName}
` - : `
Boss直聘
`; + const dictateKicker = displayCreationModeLabel("dictate"); + const head = recipeName + ? `
${dictateKicker}配方 · ${recipeName}
` + : `
${dictateKicker}
`; + stage.innerHTML = `${head}
`; + const dictateFeed = stage.querySelector("#dictate-feed"); if (panelFeed) ensureQuestionsHostIn(panelFeed); - appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, { - emptyText: - "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", - loading, - }); - if (loading) { - const pending = document.createElement("article"); - pending.className = "msg agent msg-assistant-row msg-pending"; - pending.id = "msg-live-pending"; - const live = view.liveStream; - const label = live?.label ?? view.focus?.action ?? "整理中"; - pending.innerHTML = ` -
-
${esc(label)}流式输出中${renderRetryRunButton()}
-
${renderLiveStreamBody(live, view)}
-
`; - feed.appendChild(pending); - wireRetryRun(pending, handlers); + if (dictateFeed) { + appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, { + emptyText: + "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", + loading, + }); + if (loading) { + const pending = document.createElement("article"); + pending.className = "msg agent msg-assistant-row msg-pending"; + pending.id = "msg-live-pending"; + const live = view.liveStream; + const label = live?.label ?? view.focus?.action ?? "整理中"; + pending.innerHTML = ` +
+
${esc(label)}流式输出中${renderRetryRunButton()}
+
${renderLiveStreamBody(live, view)}
+
`; + dictateFeed.appendChild(pending); + wireRetryRun(pending, handlers); + } + dictateFeed.scrollTop = dictateFeed.scrollHeight; } - feed.scrollTop = feed.scrollHeight; return; } @@ -5282,6 +5814,14 @@ export function renderMessageFeed(view, loading, handlers = {}) { } // —— 游玩 / 转述对话流 —— + if (isPlayView(view) && coordRail) { + document.body.classList.add("play-workspace"); + coordRailCollapsed = false; + document.body.classList.remove("coord-rail-collapsed"); + fillPlayAux(view, handlers, { busy: loading }); + syncCoordRailChrome(); + } + appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, { emptyText: isPlayView(view) ? "开场已就绪。在下方说你要做什么。" @@ -5342,6 +5882,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op } for (const msg of visible) { + try { const isUser = msg.role === "user"; const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); const body = msgBody(msg, view); @@ -5383,7 +5924,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op ? formatPlayPresentHtml(body, view) : kind === "worker_output" ? formatArtifactBodyHtml(body) - : formatBodyHtml(body); + : safeBodyHtml(body); const process = !isUser && isPlayProcessMessage(msg, view); const playFinal = !isUser && isPlayFinalReply(msg, view); @@ -5410,6 +5951,15 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
`; } feed.appendChild(card); + } catch (err) { + console.error("render message failed", msg?.id, err); + const fallback = document.createElement("article"); + fallback.className = "msg system msg-assistant-row"; + fallback.innerHTML = `
${esc( + msgBody(msg, view) || "(这条消息渲染失败)", + ).replace(/\n/g, "
")}
`; + feed.appendChild(fallback); + } } wireMessageFeedActions(feed, handlers); wireMessageContextMenu(feed, handlers); @@ -5534,17 +6084,25 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { document.addEventListener("click", (e) => { if (!e.target.closest("#msg-action-menu")) hideMsgMenu(); + if (!e.target.closest("#dictate-product-menu")) hideDictateProductMenu(); if (!e.target.closest("#flow-node-menu") && !e.target.closest("[data-flow-done]")) { hideFlowNodeMenu(); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { + const peek = document.getElementById("msg-peek-panel"); + if (peek && !peek.hidden) { + hideMsgPeek(); + return; + } hideMsgMenu(); + hideDictateProductMenu(); hideFlowNodeMenu(); } }); document.addEventListener("scroll", () => { hideMsgMenu(); + hideDictateProductMenu(); hideFlowNodeMenu(); }, true); diff --git a/web/app.js b/web/app.js index a172a80..0a75dbb 100644 --- a/web/app.js +++ b/web/app.js @@ -9,7 +9,7 @@ import { } from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; -import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; +import { displayCreationModeLabel, displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; import { clearQuestionCardState, collectQuestionAnswers, @@ -19,13 +19,14 @@ import { } from "./questions-ui.js"; import { applyColorTheme, - applyPresentChrome, + bumpUiFontScale, getStoredColorTheme, - getStoredPresentChrome, + getStoredUiFontScale, initColorTheme, - initPresentChrome, + initUiFontScale, themeById, - chromeById, + UI_FONT_SCALE_MAX, + UI_FONT_SCALE_MIN, } from "./theme.js"; import { applyMarkdownRender, @@ -59,9 +60,29 @@ let activePlaySaveId = null; const playSavesByBook = new Map(); const playWorkingInstanceByBook = new Map(); const playSavesLoading = new Set(); +/** @type {Map>} */ +const expandedSpecsByBook = new Map(); let bookMenuBookId = null; +let enterPickerBookId = null; let livePollTimer = null; +/** 本轮 live poll 是否已见过服务端 runActive=true(用于判断「跑完了」) */ +let livePollSawRun = false; +/** 启动轮询时的结构指纹;用于漏掉 runActive 时的收尾兜底 */ +let livePollBaselineSig = ""; +/** 结构指纹:消息/产物变化时在 loading 下整页刷新,避免主区空白 */ +let livePollStructuralSig = ""; +/** 本轮轮询开始时间 */ +let livePollStartedAt = 0; +/** 连续轮询失败次数(服务重启后 session 会 404) */ +let livePollFailCount = 0; let inflightSeq = 0; +/** 底栏菜单:只切当前 API,不编辑 */ +let apiProfileList = []; +let activeApiProfileId = null; +/** idle | loading | ready | error */ +let apiProfileMenuStatus = "idle"; +let apiProfileMenuError = ""; +let apiProfileSwitchingId = null; const LIVE_POLL_MS = 280; @@ -587,7 +608,10 @@ function shortPersonaDesc(text) { function personaPickerHtml() { const personas = globalPersonas; const active = activePersonaRecord(); - const activeName = active?.name || "玩家"; + const nested = lastView?.sessionProtagonist; + const usingOpening = lastView?.openingPersonaChoice === "opening"; + const activeName = + usingOpening && nested?.name ? nested.name : active?.name || "玩家"; const wasOpen = $("persona-menu")?.open === true; const cards = personas @@ -603,9 +627,21 @@ function personaPickerHtml() { `; } + const bindBlock = + usingOpening && nested && p.id === active?.id + ? `
+
${esc(nested.name)}
+ ${ + nested.description || nested.summary + ? `
${esc(nested.description || nested.summary)}
` + : "" + } +
` + : ""; return `
${esc(p.name)}
${esc(shortPersonaDesc(p.description))}
+ ${bindBlock}
${ @@ -846,13 +882,131 @@ function wireComposerDirectiveUi(input) { }); } +function summarizeApiProfile(profile) { + return { + id: profile.id, + name: profile.name || "未命名", + model: profile.model || "", + hasKey: Boolean(String(profile.apiKey ?? "").trim()), + }; +} + +function activeApiProfile() { + return apiProfileList.find((p) => p.id === activeApiProfileId) || null; +} + +function composerMoreMenuTitle() { + const active = activeApiProfile(); + return active ? `更多选项 · ${active.name}` : "更多选项"; +} + +function composerApiSubmenuMeta() { + if (apiProfileSwitchingId) { + const p = apiProfileList.find((x) => x.id === apiProfileSwitchingId); + return { desc: p ? `切换到 ${p.name}…` : "切换中…", title: "API" }; + } + if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") { + return { desc: "加载中…", title: "API" }; + } + if (apiProfileMenuStatus === "error") { + return { desc: "加载失败", title: "API" }; + } + if (!apiProfileList.length) { + return { desc: "未配置", title: "API" }; + } + const active = activeApiProfile(); + return { + desc: active?.name || "选择配置", + title: active ? `API · ${active.name}` : "API", + }; +} + +function composerApiProfileItemsHtml() { + if (apiProfileMenuStatus === "loading" || apiProfileMenuStatus === "idle") { + return `

加载 API 配置…

`; + } + if (apiProfileMenuStatus === "error") { + return `

无法加载 API 配置。${esc(apiProfileMenuError || "请重试")}

`; + } + if (!apiProfileList.length) { + return `

还没有 API 配置。去设置添加

`; + } + const switching = Boolean(apiProfileSwitchingId); + return apiProfileList + .map((p) => { + const active = p.id === activeApiProfileId; + const disabled = !p.hasKey || switching; + const desc = !p.hasKey ? "未设置 Key" : p.model || "未指定模型"; + return ``; + }) + .join(""); +} + +function composerApiSubmenuHtml() { + const meta = composerApiSubmenuMeta(); + const wasSubOpen = $("composer-api-submenu")?.hasAttribute("open"); + return `
+ + + API + ${esc(meta.desc)} + + + + +
`; +} + function composerMoreMenuHtml() { const on = isMarkdownRenderEnabled(); - return `
- + const wasOpen = $("composer-more-menu")?.hasAttribute("open"); + const title = composerMoreMenuTitle(); + const showClearDictate = + lastView?.creationMode === "dictate" && !isPlayView(lastView); + const clearDictateItem = showClearDictate + ? ` +
对话
+ ` + : ""; + return `
+
`; } +/** 作曲区图标按钮(tooltip = title / aria-label) */ +function composerIconBtn(opts) { + const { + act, + title, + primary = false, + kind = "button", + disabled = false, + icon, + extraClass = "", + } = opts; + const type = kind === "submit" ? "submit" : "button"; + const cls = [ + "btn", + "composer-btn-icon", + primary ? "btn-primary" : "", + extraClass, + ] + .filter(Boolean) + .join(" "); + return ``; +} + +const COMPOSER_ICON_SAVE = ``; +const COMPOSER_ICON_SEND = ``; + +function syncApiProfileMenuUi() { + const host = $("composer-api-menu"); + if (host) host.innerHTML = composerApiProfileItemsHtml(); + const meta = composerApiSubmenuMeta(); + const desc = $("composer-api-submenu-desc"); + if (desc) desc.textContent = meta.desc; + const subSum = document.querySelector("#composer-api-submenu > summary"); + if (subSum) { + subSum.title = meta.title; + subSum.setAttribute("aria-label", meta.title); + } + const sum = document.querySelector("#composer-more-menu .more-menu-sum"); + if (sum) { + const title = composerMoreMenuTitle(); + sum.title = title; + sum.setAttribute("aria-label", title); + } +} + +async function loadApiProfiles() { + apiProfileMenuStatus = "loading"; + apiProfileMenuError = ""; + syncApiProfileMenuUi(); + try { + const data = await api("/api/profiles"); + apiProfileList = (data.profiles || []).map(summarizeApiProfile); + activeApiProfileId = data.activeProfileId ?? apiProfileList[0]?.id ?? null; + apiProfileMenuStatus = "ready"; + syncApiProfileMenuUi(); + } catch (err) { + apiProfileMenuStatus = "error"; + apiProfileMenuError = err.message || "请求失败"; + syncApiProfileMenuUi(); + } +} + +function closeComposerMoreMenu(menu) { + menu?.removeAttribute("open"); + menu?.querySelector("#composer-api-submenu")?.removeAttribute("open"); +} + +async function activateApiProfileFromMenu(id, menu) { + if (!id || id === activeApiProfileId) { + closeComposerMoreMenu(menu); + return; + } + const profile = apiProfileList.find((p) => p.id === id); + if (!profile?.hasKey) return; + apiProfileSwitchingId = id; + syncApiProfileMenuUi(); + try { + const result = await api(`/api/profiles/${encodeURIComponent(id)}/activate`, { + method: "POST", + }); + activeApiProfileId = result.activeProfileId || id; + apiProfileSwitchingId = null; + syncApiProfileMenuUi(); + closeComposerMoreMenu(menu); + } catch (err) { + apiProfileSwitchingId = null; + syncApiProfileMenuUi(); + alert(err.message || "切换失败"); + } +} + function composerFormHtml(shell) { return `
${composerMoreMenuHtml()}${shell}
`; } @@ -885,13 +1133,11 @@ function syncMarkdownMenuUi() { function syncThemeMenuUi() { const colorId = document.documentElement.getAttribute("data-color-theme") || getStoredColorTheme(); - const chromeId = - document.documentElement.getAttribute("data-present-chrome") || getStoredPresentChrome(); + const percent = getStoredUiFontScale(); const colorMeta = themeById(colorId); - const chromeMeta = chromeById(chromeId); const sum = $("theme-menu-sum"); if (sum) { - sum.title = `${colorMeta.name} · ${chromeMeta.name}`; + sum.title = `${colorMeta.name} · 字号 ${percent}`; if (!sum.querySelector(".hamburger-icon")) { sum.innerHTML = ``; } @@ -899,9 +1145,12 @@ function syncThemeMenuUi() { document.querySelectorAll("[data-color-theme].more-theme-item").forEach((btn) => { btn.classList.toggle("is-active", btn.getAttribute("data-color-theme") === colorMeta.id); }); - document.querySelectorAll("[data-present-chrome].more-theme-item").forEach((btn) => { - btn.classList.toggle("is-active", btn.getAttribute("data-present-chrome") === chromeMeta.id); - }); + const valueEl = $("theme-font-scale-value"); + if (valueEl) valueEl.textContent = String(percent); + const minus = document.querySelector("[data-font-scale-bump='-1']"); + const plus = document.querySelector("[data-font-scale-bump='1']"); + if (minus) minus.disabled = percent <= UI_FONT_SCALE_MIN; + if (plus) plus.disabled = percent >= UI_FONT_SCALE_MAX; syncMarkdownMenuUi(); } @@ -1000,7 +1249,13 @@ function renderComposer(view, loading) { : ""; const shell = composerInputShell(spec, { textareaHtml: ``, - trailing: ``, + trailing: composerIconBtn({ + act: "send", + title: "发送", + primary: true, + kind: "submit", + icon: COMPOSER_ICON_SEND, + }), }); root.innerHTML = ` ${intakePanel} @@ -1151,29 +1406,30 @@ function renderComposer(view, loading) { if (spec.mode === "pick_step") { const saveProduct = canOfferSaveProduct(view) - ? `` + ? composerIconBtn({ + act: "save-product", + title: "保存定稿", + primary: true, + icon: COMPOSER_ICON_SAVE, + }) : ""; - const enterPlay = canOfferEnterPlay(view) - ? `` - : ""; - const sendCls = - canOfferSaveProduct(view) || canOfferEnterPlay(view) - ? "btn composer-btn" - : "btn btn-primary composer-btn"; + const sendClsPrimary = !canOfferSaveProduct(view); const shell = composerInputShell(spec, { textareaHtml: ``, - trailing: `${saveProduct}${enterPlay}`, + trailing: `${saveProduct}${composerIconBtn({ + act: "pick-replan", + title: spec.submitLabel || "发送", + primary: sendClsPrimary, + kind: "submit", + disabled: true, + icon: COMPOSER_ICON_SEND, + })}`, }); root.innerHTML = composerFormHtml(shell); wireComposerForm({ requiresText: true }); root.querySelector("[data-act=save-product]")?.addEventListener("click", () => { void saveCurrentInstance(); }); - root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => { - enterPlayNow(); - }); applyPendingComposerDraft(); return; } @@ -1189,47 +1445,37 @@ function renderComposer(view, loading) { : spec.acceptAction.title || spec.acceptAction.label )}" ${spec.acceptAction.disabled ? "disabled" : ""}>${esc(spec.acceptAction.label)}` : ""; - const clearDictate = - view?.creationMode === "dictate" && !isPlayView(view) - ? `` - : ""; const saveProduct = canOfferSaveProduct(view) - ? `` - : ""; - const enterPlay = canOfferEnterPlay(view) - ? `` + ? composerIconBtn({ + act: "save-product", + title: "保存定稿", + primary: true, + icon: COMPOSER_ICON_SAVE, + }) : ""; const submitLabel = spec.submitLabel || "发送"; - const sendIsPrimary = !acceptOnEmpty && !saveProduct && !enterPlay; + const sendIsPrimary = !acceptOnEmpty && !saveProduct; const placeholder = acceptOnEmpty ? `${spec.placeholder || "修改意见…"}(${spec.emptyEnterHint || "空 Enter=确认"})` : spec.placeholder; + const sendTitle = acceptOnEmpty + ? `有字或已选追问时 Enter=${submitLabel}` + : `${submitLabel}(Enter)`; const shell = composerInputShell(spec, { textareaHtml: ``, - trailing: `${clearDictate}${saveProduct}${enterPlay}${acceptBtn}`, + trailing: `${saveProduct}${composerIconBtn({ + act: "revise", + title: sendTitle, + primary: sendIsPrimary, + icon: COMPOSER_ICON_SEND, + })}${acceptBtn}`, }); root.innerHTML = composerFormHtml(shell); wireComposerForm({ acceptOnEmpty }); root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); - root.querySelector("[data-act=clear-dictate]")?.addEventListener("click", () => { - void clearDictateDialogue(); - }); root.querySelector("[data-act=save-product]")?.addEventListener("click", () => { void saveCurrentInstance(); }); - root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => { - enterPlayNow(); - }); applyPendingComposerDraft(); } @@ -1325,9 +1571,11 @@ function syncPickReplanButton(input) { if (!btn) return; const hasText = Boolean(String(input?.value ?? "").trim()); btn.disabled = !hasText; - btn.title = hasText + const tip = hasText ? "发送改编排意见" : "写下意见后再发;点图上节点即确认并进入"; + btn.title = tip; + btn.setAttribute("aria-label", tip); } function wireComposerForm(opts = {}) { @@ -1453,6 +1701,7 @@ async function fetchPlaySavesForBook(bookId, force = false) { function setBookSelectMode(on, { keepSelection = false } = {}) { if (on && sidebarNav.level !== "root") return; + if (on) hideEnterPicker(); bookSelectMode = on; if (!on) selectedBookIds = new Set(); else if (!keepSelection) selectedBookIds = new Set(); @@ -1472,6 +1721,47 @@ function hideBookMenu() { bookMenuBookId = null; } +function hideEnterPicker() { + document.querySelectorAll(".explorer-folder.is-picking").forEach((el) => { + el.classList.remove("is-picking"); + }); + const pop = $("book-enter-pop"); + if (pop) pop.hidden = true; + enterPickerBookId = null; +} + +function showEnterPicker(bookId, anchorEl) { + const pop = $("book-enter-pop"); + if (!pop || !anchorEl) return; + hideBookMenu(); + hideSaveMenu(); + enterPickerBookId = bookId; + document.querySelectorAll(".explorer-folder.is-picking").forEach((el) => { + el.classList.remove("is-picking"); + }); + anchorEl.classList.add("is-picking"); + const playBtn = pop.querySelector("[data-enter=play]"); + const ready = bookPlayReady(bookId); + if (playBtn) { + playBtn.disabled = !ready; + playBtn.title = ready ? "进入游玩" : "验收后可游玩"; + } + pop.hidden = false; + const rect = anchorEl.getBoundingClientRect(); + const left = rect.right + 8; + let top = rect.top; + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + const box = pop.getBoundingClientRect(); + if (box.bottom > window.innerHeight - 8) { + top = Math.max(8, window.innerHeight - box.height - 8); + pop.style.top = `${top}px`; + } + if (box.right > window.innerWidth - 8) { + pop.style.left = `${Math.max(8, rect.left - box.width - 8)}px`; + } +} + let saveMenuState = { bookId: null, saveId: null, label: "" }; function hideSaveMenu() { @@ -1483,6 +1773,7 @@ function hideSaveMenu() { function showSaveMenu(bookId, saveId, label, x, y) { const menu = $("save-action-menu"); if (!menu) return; + hideEnterPicker(); hideBookMenu(); saveMenuState = { bookId, saveId, label: label || "" }; menu.hidden = false; @@ -1578,8 +1869,8 @@ function refreshBookMenuLabels() { const bookId = bookMenuBookId; const ready = bookId ? bookPlayReady(bookId) : false; const thisBook = bookId && bookId === activeBookId; - $("book-menu-open")?.toggleAttribute("hidden", multi); - $("book-menu-play")?.toggleAttribute("hidden", multi || !ready); + $("book-menu-open")?.toggleAttribute("hidden", true); + $("book-menu-play")?.toggleAttribute("hidden", true); $("book-menu-save-instance")?.toggleAttribute("hidden", multi || !thisBook || !ready); $("book-menu-save-play")?.toggleAttribute( "hidden", @@ -1601,6 +1892,7 @@ function refreshBookMenuLabels() { function showBookMenu(bookId, x, y) { const menu = $("book-action-menu"); if (!menu) return; + hideEnterPicker(); hideSaveMenu(); bookMenuBookId = bookId; refreshBookMenuLabels(); @@ -1621,7 +1913,7 @@ function isCatalogNav() { } function isWorkspaceNav() { - return sidebarNav.level === "book" || sidebarNav.level === "product"; + return sidebarNav.level === "book"; } function getNavBook() { @@ -1629,9 +1921,55 @@ function getNavBook() { return books.find((b) => b.id === sidebarNav.bookId) ?? null; } -function getNavProduct() { - if (sidebarNav.level !== "product" || !sidebarNav.productId || !sidebarNav.bookId) return null; - return productInstancesForBook(sidebarNav.bookId).find((p) => p.id === sidebarNav.productId) ?? null; +function catalogBookMeta(book) { + const saves = playSavesByBook.get(book.id); + if (saves === undefined) { + void fetchPlaySavesForBook(book.id); + return ""; + } + if (playWorkingInstanceByBook.get(book.id)) return "游玩中"; + if (saves.some((s) => s.kind === "instance" || s.kind === "run")) return "可游玩"; + return "创作中"; +} + +function specOwnsWorkingPlay(book, specId) { + const playingId = playingInstanceIdForBook(book); + if (!playingId) return false; + const products = productInstancesForBook(book.id); + if (products.some((p) => p.id === playingId)) return playingId === specId; + return products[0]?.id === specId; +} + +function defaultExpandedSpecIds(bookId) { + const products = productInstancesForBook(bookId); + const playing = playingInstanceIdForBook({ id: bookId }); + const target = + playing && products.some((p) => p.id === playing) ? playing : products[0]?.id; + return target ? new Set([target]) : new Set(); +} + +function specIsExpanded(bookId, specId) { + const set = expandedSpecsByBook.get(bookId); + if (set) return set.has(specId); + return defaultExpandedSpecIds(bookId).has(specId); +} + +function toggleSpecExpanded(bookId, specId) { + const next = new Set(expandedSpecsByBook.get(bookId) ?? defaultExpandedSpecIds(bookId)); + if (next.has(specId)) next.delete(specId); + else next.add(specId); + expandedSpecsByBook.set(bookId, next); + renderBookList(); +} + +function ensureSpecExpanded(bookId, specId) { + if (!bookId) return; + if (specId) { + const next = new Set(expandedSpecsByBook.get(bookId) ?? defaultExpandedSpecIds(bookId)); + next.add(specId); + expandedSpecsByBook.set(bookId, next); + } + navigateToBook(bookId); } function pathSep() { @@ -1664,11 +2002,9 @@ function renderPathBar() { if (!bar) return; bar.innerHTML = ""; const book = getNavBook(); - const product = getNavProduct(); - const atProduct = sidebarNav.level === "product" && Boolean(book); - const inBook = Boolean(book) && (sidebarNav.level === "book" || atProduct); + const inBook = Boolean(book); - $("rail-tabs")?.toggleAttribute("hidden", !isWorkspaceNav()); + $("rail-tabs")?.toggleAttribute("hidden", !(isWorkspaceNav() || Boolean(activeBookId))); const list = document.createElement("ol"); list.className = "path-bar-list"; @@ -1677,28 +2013,16 @@ function renderPathBar() { label: "目录", title: "作品目录", current: !inBook, - onClick: () => showCatalog(), + onClick: () => showCatalog({ closeSession: false }), }), ); if (inBook) { - const bookLabel = book.title?.trim() || "书本"; + const bookLabel = book.title?.trim() || "作品"; list.appendChild(pathSep()); list.appendChild( pathItem({ label: bookLabel, title: bookLabel, - current: !atProduct, - onClick: () => navigateToBook(book.id), - }), - ); - } - if (atProduct) { - const productLabel = product?.label?.trim() || "定稿"; - list.appendChild(pathSep()); - list.appendChild( - pathItem({ - label: productLabel, - title: productLabel, current: true, }), ); @@ -1708,6 +2032,7 @@ function renderPathBar() { function navigateToBook(bookId) { if (!bookId) return; + hideEnterPicker(); sidebarNav = { level: "book", bookId }; document.body.dataset.navLevel = "book"; bookSelectMode = false; @@ -1718,26 +2043,17 @@ function navigateToBook(bookId) { void fetchPlaySavesForBook(bookId); } -function navigateToProduct(bookId, productId) { - if (!bookId || !productId) return; - sidebarNav = { level: "product", bookId, productId }; - document.body.dataset.navLevel = "product"; - bookSelectMode = false; - selectedBookIds = new Set(); - setRailTab("books", { expand: true }); - renderPathBar(); - renderBookList(); - void fetchPlaySavesForBook(bookId); -} - -function showCatalog() { - beginUiRequest(); - stopLivePoll(); +function showCatalog({ closeSession = true } = {}) { + hideEnterPicker(); + if (closeSession) { + beginUiRequest(); + stopLivePoll(); + closeWorkspace(); + } sidebarNav = { level: "root" }; document.body.dataset.navLevel = "root"; bookSelectMode = false; selectedBookIds = new Set(); - closeWorkspace(); renderPathBar(); renderBookList(); } @@ -1758,6 +2074,7 @@ function renderExplorerRow({ expanded, onTwistie, title, + ariaExpanded, }) { const row = document.createElement("button"); row.type = "button"; @@ -1773,6 +2090,9 @@ function renderExplorerRow({ .join(" "); if (bookId) row.dataset.bookId = bookId; if (title) row.title = title; + if (typeof ariaExpanded === "boolean") { + row.setAttribute("aria-expanded", ariaExpanded ? "true" : "false"); + } row.innerHTML = ` ${twistie ? `${expanded ? "▾" : "▸"}` : ""} @@ -1791,10 +2111,10 @@ function renderExplorerRow({ onRename(); }); } - row.querySelector("[data-twist]")?.addEventListener("click", (e) => { - e.stopPropagation(); - (onTwistie || onClick)?.(); - }); + row.querySelector("[data-twist]")?.addEventListener("click", (e) => { + e.stopPropagation(); + (onTwistie || onClick)?.(); + }); row.querySelector(".explorer-del")?.addEventListener("click", (e) => { e.stopPropagation(); onDelete?.(); @@ -1824,41 +2144,24 @@ function playingInstanceIdForBook(book) { return playWorkingInstanceByBook.get(book.id) ?? null; } -function renderProductContents(book, product, list) { - const loading = playSavesLoading.has(book.id); - const saves = playSavesByBook.get(book.id); - if (loading && saves === undefined) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; - list.appendChild(hint); - return; - } - +function renderSpecChildren(book, product, list) { const playHere = book.id === activeBookId && isPlayNow(); - const playingInstanceId = playingInstanceIdForBook(book); - const hasWorking = playingInstanceId === product.id; - const runs = runsForProduct(book.id, product.id, productInstancesForBook(book.id)); - - if (hasWorking) { + if (specOwnsWorkingPlay(book, product.id)) { + const live = playHere && !activePlaySaveId; list.appendChild( renderExplorerRow({ name: "当前游玩", - meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", - active: playHere && !activePlaySaveId, - onClick: () => void openCurrentPlay(book.id), + meta: live ? "游玩中" : "", + active: live, + actionClass: "explorer-run", + child: true, + onClick: () => void loadPlaySave(book.id, "play-working"), onContextMenu: (e) => showSaveMenu(book.id, "play-working", "当前游玩", e.clientX, e.clientY), }), ); } - - if (!runs.length && !hasWorking) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "还没有游玩存档"; - list.appendChild(hint); - } + const runs = runsForProduct(book.id, product.id, productInstancesForBook(book.id)); for (const run of runs) { const runWhen = formatSaveWhen(run.createdAt); @@ -1867,7 +2170,8 @@ function renderProductContents(book, product, list) { name: run.label, meta: runWhen || "游玩进度", active: playHere && activePlaySaveId === run.id, - actionClass: "explorer-file", + actionClass: "explorer-run", + child: true, onClick: () => void loadPlaySave(book.id, run.id), onRename: () => void renamePlaySave(book.id, run.id, run.label), onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"), @@ -1876,26 +2180,39 @@ function renderProductContents(book, product, list) { }), ); } +} +function appendNewPlayRow(book, list, instanceId, { child = false } = {}) { list.appendChild( renderExplorerRow({ name: "+ 新开一局", actionClass: "explorer-action", - onClick: () => void startNewPlayForBook(book.id, product.id), + child, + onClick: () => void startNewPlayForBook(book.id, instanceId), + }), + ); +} + +function appendCurrentDesignRow(book, list) { + const live = + book.id === activeBookId && Boolean(sessionId) && !isPlayNow(); + list.appendChild( + renderExplorerRow({ + name: "当前创作", + meta: live ? "进行中" : "继续", + active: live, + actionClass: "explorer-design", + title: "打开创作会话;定稿会出现在下方", + onClick: () => void resumeDesignSession(book.id), }), ); } function renderBookContents(book, list) { - const designing = book.id === activeBookId && !isPlayNow(); - list.appendChild( - renderExplorerRow({ - name: "继续创作", - meta: designing ? "进行中" : "", - active: designing, - onClick: () => void openBookDesign(book.id), - }), - ); + const playingInstanceId = playingInstanceIdForBook(book); + const products = productInstancesForBook(book.id); + + appendCurrentDesignRow(book, list); const loading = playSavesLoading.has(book.id); const saves = playSavesByBook.get(book.id); @@ -1907,40 +2224,45 @@ function renderBookContents(book, list) { return; } - const products = productInstancesForBook(book.id); if (!products.length) { const hint = document.createElement("p"); hint.className = "explorer-hint"; - hint.textContent = bookPlayReady(book.id) - ? "还没有定稿。可在更多菜单里保存定稿。" - : "验收后可保存定稿。"; + hint.textContent = bookPlayReady(book.id) ? "还没有规格" : "定稿后会出现在这里"; list.appendChild(hint); + if (bookPlayReady(book.id)) appendNewPlayRow(book, list); return; } - const latestId = products[0]?.id; - const playingInstanceId = playingInstanceIdForBook(book); - const section = document.createElement("p"); - section.className = "explorer-section-label"; - section.textContent = "定稿"; - list.appendChild(section); - + let anyExpanded = false; for (const product of products) { + const expanded = specIsExpanded(book.id, product.id); const bits = []; - if (product.id === latestId) bits.push("最新"); - if (playingInstanceId === product.id) bits.push("游玩中"); + if (!expanded && product.id === playingInstanceId) bits.push("游玩中"); list.appendChild( renderExplorerRow({ name: product.label, meta: bits.join(" · "), - title: "打开定稿,双击改名;右键编辑产物", - onClick: () => navigateToProduct(book.id, product.id), + title: "展开或收起;双击改名;右键编辑产物", + actionClass: "explorer-spec", + twistie: true, + expanded, + ariaExpanded: expanded, + onClick: () => toggleSpecExpanded(book.id, product.id), + onTwistie: () => toggleSpecExpanded(book.id, product.id), onRename: () => void renamePlaySave(book.id, product.id, product.label), - onDelete: () => void deletePlaySave(book.id, product.id, product.label, "定稿"), + onDelete: () => void deletePlaySave(book.id, product.id, product.label, "规格"), onContextMenu: (e) => showSaveMenu(book.id, product.id, product.label, e.clientX, e.clientY), }), ); + if (expanded) { + anyExpanded = true; + renderSpecChildren(book, product, list); + appendNewPlayRow(book, list, product.id, { child: true }); + } + } + if (!anyExpanded) { + appendNewPlayRow(book, list, playingInstanceId || products[0]?.id); } } @@ -1955,34 +2277,6 @@ function renderBookList() { } list.innerHTML = ""; - if (sidebarNav.level === "product") { - const book = getNavBook(); - if (!book) { - sidebarNav = { level: "root" }; - document.body.dataset.navLevel = "root"; - renderPathBar(); - } else { - const product = getNavProduct(); - const loading = playSavesLoading.has(book.id) && !playSavesByBook.has(book.id); - if (product) { - renderProductContents(book, product, list); - return; - } - if (loading) { - const hint = document.createElement("p"); - hint.className = "explorer-hint"; - hint.textContent = "加载存档…"; - list.appendChild(hint); - return; - } - sidebarNav = { level: "book", bookId: book.id }; - document.body.dataset.navLevel = "book"; - renderPathBar(); - renderBookContents(book, list); - return; - } - } - if (sidebarNav.level === "book") { const book = getNavBook(); if (book) { @@ -1995,25 +2289,35 @@ function renderBookList() { } 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, + meta: catalogBookMeta(book), + active: book.id === activeBookId, selected, bookId: book.id, actionClass: "explorer-folder", - onClick: () => { + onClick: (e) => { if (bookSelectMode) { toggleBookSelection(book.id); return; } - navigateToBook(book.id); + e.stopPropagation(); + if (enterPickerBookId === book.id) { + hideEnterPicker(); + return; + } + showEnterPicker(book.id, e.currentTarget); }, }), ); } + if (enterPickerBookId && sidebarNav.level === "root") { + const row = list.querySelector(`[data-book-id="${enterPickerBookId}"]`); + if (row) showEnterPicker(enterPickerBookId, row); + else hideEnterPicker(); + } } async function renameBookById(bookId) { @@ -2176,8 +2480,8 @@ async function loadPlaySave(bookId, saveId) { try { const products = productInstancesForBook(bookId); const run = playRunsForBook(bookId).find((r) => r.id === saveId); - const instanceId = run ? resolveRunInstanceId(run, products) : sidebarNav.productId; - if (instanceId) navigateToProduct(bookId, instanceId); + const instanceId = run ? resolveRunInstanceId(run, products) : null; + if (instanceId) ensureSpecExpanded(bookId, instanceId); else navigateToBook(bookId); if (lastView?.bookId === bookId) renderSession(lastView, true); const data = await api( @@ -2217,10 +2521,8 @@ async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") { method: "DELETE" }, ); if (activePlaySaveId === saveId) activePlaySaveId = null; - if (sidebarNav.level === "product" && sidebarNav.productId === saveId) { - sidebarNav = { level: "book", bookId }; - document.body.dataset.navLevel = "book"; - } + const expanded = expandedSpecsByBook.get(bookId); + expanded?.delete(saveId); await fetchPlaySavesForBook(bookId, true); } catch (err) { alert(err.message); @@ -2234,9 +2536,13 @@ async function startNewPlayForBook(bookId, instanceId) { return; } try { - if (instanceId) navigateToProduct(bookId, instanceId); + if (instanceId) ensureSpecExpanded(bookId, instanceId); else navigateToBook(bookId); - if (lastView?.bookId === bookId) renderSession(lastView, true); + if (activeBookId !== bookId || !sessionId) { + await openBook(bookId); + } else if (lastView?.bookId === bookId) { + renderSession(lastView, true); + } const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST", body: JSON.stringify(instanceId ? { instanceId } : {}), @@ -2250,22 +2556,36 @@ async function startNewPlayForBook(bookId, instanceId) { } } -async function openBookDesign(bookId) { - navigateToBook(bookId); +async function resumeDesignSession(bookId) { activePlaySaveId = null; - if (activeBookId !== bookId || !sessionId) { - await openBook(bookId); + try { + if (activeBookId !== bookId || !sessionId) { + await openBook(bookId); + } + if (isPlayNow()) { + await setLifecycle("design"); + return; + } + renderPathBar(); + renderBookList(); + } catch (err) { + alert(err.message || "打开创作失败"); } - if (isPlayNow()) { - await setLifecycle("design"); - return; - } - renderBookList(); +} + +/** 点「创作」:进入该作品快照列表,并打开/恢复创作会话 */ +async function openBookDesign(bookId) { + hideEnterPicker(); + bookSelectMode = false; + selectedBookIds = new Set(); + navigateToBook(bookId); + await resumeDesignSession(bookId); } async function openCurrentPlay(bookId) { + hideEnterPicker(); const instanceId = playingInstanceIdForBook({ id: bookId }); - if (instanceId) navigateToProduct(bookId, instanceId); + if (instanceId) ensureSpecExpanded(bookId, instanceId); else navigateToBook(bookId); const alreadyHere = activeBookId === bookId && @@ -2327,6 +2647,7 @@ function renderHeader(view, loading) { } function closeWorkspace({ emptyCopy = "" } = {}) { + hideEnterPicker(); stopLivePoll(); sessionId = null; lastView = null; @@ -2393,6 +2714,11 @@ function stopLivePoll() { clearInterval(livePollTimer); livePollTimer = null; } + livePollSawRun = false; + livePollBaselineSig = ""; + livePollStructuralSig = ""; + livePollStartedAt = 0; + livePollFailCount = 0; } function beginUiRequest() { @@ -2403,6 +2729,51 @@ function isStaleUiRequest(seq) { return seq !== inflightSeq; } +function sessionStructuralSig(view) { + const messages = view?.messages ?? []; + const last = messages[messages.length - 1]; + const products = view?.dictateProducts ?? []; + return [ + messages.length, + last?.id ?? "", + products.length, + products.map((p) => p.tag).join(","), + view?.phase ?? "", + view?.waitingReason?.kind ?? "", + view?.runActive ? "1" : "0", + ].join("|"); +} + +/** 用服务端最新 view 结束本地「生成中」,并作废悬挂的 POST */ +function finishUiLoading(view) { + beginUiRequest(); + renderSession(view, false); +} + +async function recoverBusySession() { + if (!sessionId) return false; + try { + const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`); + finishUiLoading(view); + return true; + } catch { + const bookId = lastView?.bookId || activeBookId; + if (!bookId) return false; + try { + const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { + method: "POST", + }); + if (data?.session) { + finishUiLoading(data.session); + return true; + } + } catch { + /* fall through */ + } + } + return false; +} + async function retryRun() { if (!sessionId) return; const seq = beginUiRequest(); @@ -2416,7 +2787,10 @@ async function retryRun() { renderSession(view, false); } catch (err) { if (isStaleUiRequest(seq)) return; - if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + const recovered = await recoverBusySession(); + if (!recovered && lastView) { + renderSession({ ...lastView, hints: [err.message] }, false); + } } } @@ -2433,7 +2807,14 @@ async function abortRun() { renderSession(view, false); } catch (err) { if (isStaleUiRequest(seq)) return; - if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + // 请求已在服务端结束、或会话因重启丢失:停止也要能退出「生成中」 + const recovered = await recoverBusySession(); + if (!recovered && lastView) { + renderSession( + { ...lastView, hints: [err.message || "已停止"] }, + false, + ); + } } } @@ -2455,22 +2836,67 @@ async function clearDictateDialogue() { } function startLivePoll() { - stopLivePoll(); + // 已在轮询时不要重置:loading 下因消息落盘而 renderSession(..., true) 会再次调用 + if (livePollTimer != null) return; if (!sessionId) return; + livePollSawRun = false; + livePollFailCount = 0; + livePollBaselineSig = sessionStructuralSig(lastView); + livePollStructuralSig = livePollBaselineSig; + livePollStartedAt = Date.now(); livePollTimer = setInterval(async () => { - if (!sessionId) return; + if (!sessionId || livePollTimer == null) return; try { const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}`); - lastView = { ...lastView, liveStream: view.liveStream, agentThinking: view.agentThinking }; + if (!sessionId || livePollTimer == null) return; + livePollFailCount = 0; + + if (view.runActive) livePollSawRun = true; + + const sig = sessionStructuralSig(view); + const progressed = sig !== livePollBaselineSig; + const elapsed = Date.now() - livePollStartedAt; + // 服务端已空闲且:见过 run / 或内容相对启动时已推进 + const serverDone = + !view.runActive && + (livePollSawRun || (progressed && elapsed > 600)); + if (serverDone) { + finishUiLoading(view); + return; + } + + if (sig !== livePollStructuralSig) { + livePollStructuralSig = sig; + // 保持 loading 底栏,但把已写入的用户句/产物/回复刷进主区 + renderSession(view, true); + return; + } + + lastView = { + ...lastView, + liveStream: view.liveStream, + agentThinking: view.agentThinking, + runActive: view.runActive, + dictateProducts: view.dictateProducts ?? lastView.dictateProducts, + }; updateLiveStreamPanel(view); } catch { - /* ignore transient poll errors */ + livePollFailCount += 1; + // 服务重启后内存会话丢失:重开作品收尾,避免永远转圈 + if (livePollFailCount >= 3) { + const ok = await recoverBusySession(); + if (!ok && lastView) { + finishUiLoading({ + ...lastView, + hints: ["连接中断,请点左侧作品重新打开"], + }); + } + } } }, LIVE_POLL_MS); } function renderSession(view, loading = false) { - if (isCatalogNav()) return; lastView = view; sessionId = view.id; activeBookId = view.bookId ?? activeBookId; @@ -2521,6 +2947,12 @@ function renderSession(view, loading = false) { onLeaveCreationStep: () => runAction("leave_step"), onEnterPlay: () => enterPlayNow(), onSaveProduct: () => void saveCurrentInstance(), + onPatchTable: (tag, cells) => void patchPlayTable(tag, cells), + onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona), + onRefuseOpeningPersona: () => { + const idx = lastView?.playAux?.selectedOpeningIndex ?? 0; + void selectPlayOpening(idx, false); + }, }); renderComposer(view, loading); if (loading) startLivePoll(); @@ -2731,7 +3163,23 @@ async function sendText(text) { } try { beginComposerSubmit(); - renderSession(lastView, true); + const optimistic = lastView + ? { + ...lastView, + messages: [ + ...(lastView.messages ?? []), + { + id: `local-${Date.now()}`, + role: "user", + kind: "user_input", + text: trimmed, + body: trimmed, + createdAt: new Date().toISOString(), + }, + ], + } + : lastView; + renderSession(optimistic || lastView, true); const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: "POST", body: JSON.stringify({ text: trimmed }), @@ -2743,7 +3191,10 @@ async function sendText(text) { if (isStaleUiRequest(seq)) return; keepComposerDraft(trimmed); if (cardOpen && qHost) qHost._qDismissed = null; - if (lastView) renderSession({ ...lastView, hints: [err.message] }, false); + const recovered = await recoverBusySession(); + if (!recovered && lastView) { + renderSession({ ...lastView, hints: [err.message] }, false); + } } finally { endComposerSubmit(); } @@ -2793,10 +3244,53 @@ function enterPlayNow() { void openCurrentPlay(activeBookId); return; } - void setLifecycle("play"); + void (async () => { + await setLifecycle("play"); + })(); } -async function setLifecycle(stage) { +async function patchPlayTable(tag, cells) { + if (!sessionId) return; + try { + const data = await api(`/api/sessions/${encodeURIComponent(sessionId)}/table`, { + method: "POST", + body: JSON.stringify({ tag, cells }), + }); + const skipped = data.tablePatch?.skipped ?? []; + const conflicts = skipped.filter((s) => String(s.reason || "").includes("rev")); + if (conflicts.length) { + alert( + `有字段已被更新,未写入:${conflicts.map((c) => c.key).join("、")}。请再改一次。`, + ); + } + renderSession(data, false); + } catch (err) { + alert(err.message || "无法改表"); + if (lastView) renderSession(lastView, false); + } +} + +async function selectPlayOpening(index, usePersona) { + if (!sessionId) return; + try { + const view = await api( + `/api/sessions/${encodeURIComponent(sessionId)}/play/opening`, + { + method: "POST", + body: JSON.stringify({ + index, + ...(typeof usePersona === "boolean" ? { usePersona } : {}), + }), + }, + ); + renderSession(view, false); + } catch (err) { + alert(err.message || "无法选用开场"); + if (lastView) renderSession(lastView, false); + } +} + +async function setLifecycle(stage, opts) { if (!sessionId) return; if (stage === lastView?.lifecycleStage) { if (stage === "design") return; @@ -2815,9 +3309,19 @@ async function setLifecycle(stage) { try { const view = await api(`/api/sessions/${encodeURIComponent(sessionId)}/lifecycle`, { method: "POST", - body: JSON.stringify({ stage }), + body: JSON.stringify({ + stage, + ...(typeof opts?.useOpeningPersona === "boolean" + ? { useOpeningPersona: opts.useOpeningPersona } + : {}), + }), }); - if (stage === "design") activePlaySaveId = null; + if (stage === "design") { + activePlaySaveId = null; + if (activeBookId) navigateToBook(activeBookId); + } else if (stage === "play" && activeBookId) { + navigateToBook(activeBookId); + } renderSession(view, false); if (activeBookId) void fetchPlaySavesForBook(activeBookId, true); } catch (err) { @@ -2900,8 +3404,8 @@ async function populateDirectorSelect() { if (modeDesc) { modeDesc.hidden = false; modeDesc.textContent = dictate - ? "Boss 直聘:对话写入产物;变量/映射用工具钉死;下方为直聘专用配方。" - : "Worker 式:按工序图逐步验收;下方为编排专用配方。"; + ? `${displayCreationModeLabel("dictate")}:对话写入产物;变量/映射用工具钉死;下方为对应配方。` + : `${displayCreationModeLabel("recipe")}:按工作流计划逐步验收技能;下方为对应配方。`; } syncDesc(); }; @@ -2963,7 +3467,8 @@ async function createBook() { $("dialog-new-book").close(); if (data.book) books.unshift(data.book); activeBookId = data.book?.id; - navigateToBook(data.book.id); + sidebarNav = { level: "root" }; + document.body.dataset.navLevel = "root"; renderSession(data.session, false); } catch (err) { alert(err.message); @@ -2975,7 +3480,6 @@ async function createBook() { async function openBook(bookId) { const seq = beginUiRequest(); activeBookId = bookId; - navigateToBook(bookId); if (lastView?.bookId === bookId) renderSession(lastView, true); try { const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" }); @@ -3062,7 +3566,8 @@ async function saveCurrentInstance() { } await fetchPlaySavesForBook(activeBookId, true); const newest = productInstancesForBook(activeBookId)[0]; - if (newest) navigateToProduct(activeBookId, newest.id); + if (newest) ensureSpecExpanded(activeBookId, newest.id); + else navigateToBook(activeBookId); } catch (err) { alert(err.message); } @@ -3367,6 +3872,18 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => { }); $("btn-new-book")?.addEventListener("click", openNewBookDialog); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); +$("book-enter-pop")?.addEventListener("click", (e) => { + e.stopPropagation(); + const btn = e.target.closest("[data-enter]"); + if (!btn || btn.disabled) return; + const enter = btn.getAttribute("data-enter"); + const bookId = enterPickerBookId; + hideEnterPicker(); + if (!bookId) return; + if (enter === "design") void openBookDesign(bookId); + else if (enter === "play") void openCurrentPlay(bookId); +}); + $("book-action-menu")?.addEventListener("click", (e) => { const action = e.target.closest("[data-action]")?.getAttribute("data-action"); const bookId = bookMenuBookId; @@ -3406,17 +3923,25 @@ $("book-action-menu")?.addEventListener("click", (e) => { document.addEventListener("click", (e) => { if (!e.target.closest("#book-action-menu")) hideBookMenu(); if (!e.target.closest("#save-action-menu")) hideSaveMenu(); + if ( + !e.target.closest("#book-enter-pop") && + !e.target.closest(".explorer-folder") + ) { + hideEnterPicker(); + } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideBookMenu(); hideSaveMenu(); + hideEnterPicker(); if (bookSelectMode || selectedBookIds.size) setBookSelectMode(false); } }); document.addEventListener("scroll", () => { hideBookMenu(); hideSaveMenu(); + hideEnterPicker(); }, true); $("save-action-menu")?.addEventListener("click", (e) => { @@ -3527,28 +4052,49 @@ $("theme-menu")?.addEventListener("click", (e) => { menu.removeAttribute("open"); return; } - const chromeBtn = e.target.closest("[data-present-chrome]"); - if (chromeBtn && menu.contains(chromeBtn)) { + const bumpBtn = e.target.closest("[data-font-scale-bump]"); + if (bumpBtn && menu.contains(bumpBtn)) { e.preventDefault(); - applyPresentChrome(chromeBtn.getAttribute("data-present-chrome")); + e.stopPropagation(); + bumpUiFontScale(bumpBtn.getAttribute("data-font-scale-bump")); syncThemeMenuUi(); - menu.removeAttribute("open"); } }); $("composer")?.addEventListener("click", (e) => { const menu = e.target.closest("#composer-more-menu"); + if (!menu) return; + const clearDictateBtn = e.target.closest("[data-act=clear-dictate]"); + if (clearDictateBtn && menu.contains(clearDictateBtn)) { + e.preventDefault(); + closeComposerMoreMenu(menu); + void clearDictateDialogue(); + return; + } const mdBtn = e.target.closest("[data-toggle-markdown]"); - if (!mdBtn || !menu?.contains(mdBtn)) return; - e.preventDefault(); - applyMarkdownRender(!isMarkdownRenderEnabled()); - syncMarkdownMenuUi(); - if (lastView && sessionId) renderSession(lastView, false); + if (mdBtn && menu.contains(mdBtn)) { + e.preventDefault(); + applyMarkdownRender(!isMarkdownRenderEnabled()); + syncMarkdownMenuUi(); + if (lastView && sessionId) renderSession(lastView, false); + return; + } + const reloadBtn = e.target.closest("[data-reload-profiles]"); + if (reloadBtn && menu.contains(reloadBtn)) { + e.preventDefault(); + void loadApiProfiles(); + return; + } + const profileBtn = e.target.closest("[data-activate-profile]"); + if (profileBtn && menu.contains(profileBtn)) { + e.preventDefault(); + void activateApiProfileFromMenu(profileBtn.getAttribute("data-activate-profile"), menu); + } }); window.addEventListener("wa:session-updated", (e) => { const view = e.detail; - if (!view?.id || isCatalogNav() || !sessionId) return; + if (!view?.id || !sessionId) return; if (view.id !== sessionId) return; renderSession(view, false); }); @@ -3559,18 +4105,23 @@ window.addEventListener("wa:nav-root", () => { document.addEventListener("click", (e) => { document.querySelectorAll("details.more-menu[open]").forEach((d) => { - if (!d.contains(e.target)) d.removeAttribute("open"); + if (!d.contains(e.target)) { + d.removeAttribute("open"); + d.querySelectorAll("details.more-submenu[open]").forEach((sub) => { + sub.removeAttribute("open"); + }); + } }); }); async function init() { initColorTheme(); - initPresentChrome(); + initUiFontScale(); initMarkdownRender(); syncThemeMenuUi(); try { void populateDirectorSelect(); - await Promise.all([loadBooks(), loadPersonas()]); + await Promise.all([loadBooks(), loadPersonas(), loadApiProfiles()]); if (books.length) { showCatalog(); } else { diff --git a/web/display-labels.js b/web/display-labels.js index 470e1d5..885391a 100644 --- a/web/display-labels.js +++ b/web/display-labels.js @@ -1,6 +1,6 @@ /** * 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。 - * 用户侧口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。 + * 用户侧口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。 */ const STAGE_LABELS = { @@ -37,9 +37,12 @@ const FIXED_TOPIC_LABELS = { "aesthetics-interaction": "美学纲领与交互范式", interaction: "交互范式", narrative_guide: "叙事指南与故事推进", + "narrative-guide": "叙事指南", + "story-progression": "故事推进", input_protocol: "输入协议", core_premise: "核心前提", aesthetics: "美学纲领", + protagonist: "主角设定", }; const PHASE_UNIT_LABELS = { @@ -52,11 +55,21 @@ export function displayStageLabel(id) { return STAGE_LABELS[id] ?? id; } +export function displayCreationModeLabel(id) { + if (!id) return ""; + const map = { + recipe: "工序编排", + dictate: "对话落盘", + }; + return map[id] ?? id; +} + export function displaySkillPackLabel(id) { if (!id) return ""; const map = { - "world-simulator": "世界模拟器", + "world-simulator": "回合推演", "expand-assistant": "扩写助手", + 快穿短局: "快穿短局", 文本生成器: "文本生成器", "text-generator": "文本生成器", 交互式长文生成器: "交互式长文生成器", diff --git a/web/index.html b/web/index.html index 0bd27d9..011edc9 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap" rel="stylesheet" /> - + @@ -36,7 +41,7 @@ aria-controls="rail-body" title="展开侧栏" > - 书 + 作品
@@ -54,9 +59,9 @@
-
@@ -198,8 +196,8 @@ 进料方式 @@ -221,6 +219,11 @@ + + + + + +