接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。

直聘插入按模块回馈,并记录请求骨架便于排查。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 07:40:08 +08:00
co-authored by Cursor
parent 9306c8e659
commit dd0a9e87d8
88 changed files with 6869 additions and 786 deletions
+1
View File
@@ -10,6 +10,7 @@
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
| 具体实例 | 通用 fragment / 结构化 | context-fragment 外壳 | | 具体实例 | 通用 fragment / 结构化 | context-fragment 外壳 |
| 叙事指南与故事推进 | **专用 mosaic**(风格与写法 \| 推进与决策) | `renderNarrativeBodyHtml` | | 叙事指南与故事推进 | **专用 mosaic**(风格与写法 \| 推进与决策) | `renderNarrativeBodyHtml` |
| 叙事指南 / 故事推进 | 同上 mosaic(落盘拆开后各用半栏) | `renderNarrativeBodyHtml` |
| 变量设计 | **结构化卡**(真值 / side_effects) | `renderVariableDesignHtml` | | 变量设计 | **结构化卡**(真值 / side_effects) | `renderVariableDesignHtml` |
| 变量控制上下文 | 通用 fragment | context-fragment | | 变量控制上下文 | 通用 fragment | context-fragment |
| 正文组成 | **专用卡**:present 壳渲染 `正文.示例.灌数`(看美化)+ 契约折叠 | `renderReplyFormatBodyHtml` | | 正文组成 | **专用卡**:present 壳渲染 `正文.示例.灌数`(看美化)+ 契约折叠 | `renderReplyFormatBodyHtml` |
+9 -4
View File
@@ -139,13 +139,16 @@
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。 **禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。
〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。 〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。
## 2.5 已合并技能(勿拆回) ## 2.5 合并与按进料拆开
| 错误拆法 | 正确技能 | | 错误拆法 | 正确技能 |
|----------|----------| |----------|----------|
| 「交互范式」+「美学纲领」两步 | **美学纲领与交互范式**(`aesthetics-interaction`)一步 | | 「交互范式」+「美学纲领」两步 | **美学纲领与交互范式**(`aesthetics-interaction`)一步 |
询问相近则合并为一步;不要在新技能里建议再拆开。 询问主题高度重叠、**调用条件相同** → 合成一个能力(如美学+交互)。
调用条件不同则分技能。工序编排「叙事指南与故事推进」保持合并;对话落盘拆成「叙事指南」与「故事推进」(catalog `intake`)。
不要在美学步里建议再拆回交互/美学两步。
## 2.6 命名纪律 ## 2.6 命名纪律
@@ -200,7 +203,7 @@ catalog 登记(短声明给编排看;可选 repeatable)
- **正推**:需要 [体验/产物] → 才纳入某技能;勿默认全选池内能力。调用条件写在该技能 `when` / `when_not`,不要写进规划 SKILL 或配方 process。 - **正推**:需要 [体验/产物] → 才纳入某技能;勿默认全选池内能力。调用条件写在该技能 `when` / `when_not`,不要写进规划 SKILL 或配方 process。
- **边界**:每个能力在 `meta.boundary` 写清「本步定什么 / 不定什么 / 交给谁」。 - **边界**:每个能力在 `meta.boundary` 写清「本步定什么 / 不定什么 / 交给谁」。
- **依赖**:由工作流计划 `depends_on` 声明;执行期程序注入对应产物。 - **依赖**:由工作流计划 `depends_on` 声明;执行期程序注入对应产物。
- **合并**:询问主题高度重叠 → 合成一个能力(如美学+交互)。 - **合并**:询问主题高度重叠且调用条件相同 → 合成一个能力(如美学+交互)。调用条件不同则分技能(对话落盘「叙事指南」/「故事推进」)。
- **缩减**:每增必问能否删、能否常驻替代、能否合并。 - **缩减**:每增必问能否删、能否常驻替代、能否合并。
## 3.5 方法层硬禁止(写任何能力都适用) ## 3.5 方法层硬禁止(写任何能力都适用)
@@ -488,7 +491,9 @@ artifact:
| 舞台骨架 | `world-blueprint` | 已写(`context-fragment.v1`;社会结构 + 世界状况) | | 舞台骨架 | `world-blueprint` | 已写(`context-fragment.v1`;社会结构 + 世界状况) |
| 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;合同严、正文可长) | | 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;合同严、正文可长) |
| 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;只按规则执行) | | 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;只按规则执行) |
| 叙事指南与故事推进 | `narrative` | 已写(`context-fragment.v1`;遣词/笔墨/禁忌+推进;旧称叙事指南) | | 叙事指南与故事推进 | `narrative` | 工序编排(`context-fragment.v1`;遣词/笔墨/禁忌+推进) |
| 叙事指南 | `narrative-guide` | 对话落盘;遣词/笔墨/禁忌/写法档 |
| 故事推进 | `story-progression` | 对话落盘;推进 + 用户输入用法 |
| 正文组成 | `reply-format` | 已写(壳适配 + 示例 present 预览看美化;旧称设计回复格式) | | 正文组成 | `reply-format` | 已写(壳适配 + 示例 present 预览看美化;旧称设计回复格式) |
| 设计监控栏 | `status-bar` | 已写(只盯会变信息;旧称设计状态栏) | | 设计监控栏 | `status-bar` | 已写(只盯会变信息;旧称设计状态栏) |
| 开场白与开场变量 | `opening-setup` | 已写(〔收口〕:1~多条开场;选定后落库并保存定稿) | | 开场白与开场变量 | `opening-setup` | 已写(〔收口〕:1~多条开场;选定后落库并保存定稿) |
+3 -1
View File
@@ -20,7 +20,9 @@
| 舞台骨架 | 会换区才备案;代入后几乎不移动、只在一处待着(网聊/门卫室)不排 | | 舞台骨架 | 会换区才备案;代入后几乎不移动、只在一处待着(网聊/门卫室)不排 |
| 生成规则 | 时机 a 精准格式或时机 b 原创会雾且还要生成,满足一条就排 | | 生成规则 | 时机 a 精准格式或时机 b 原创会雾且还要生成,满足一条就排 |
| 具体实例 | 已有 seed 类规则才排;`runtime_only` 不排;本步不改合同(已审查,基本不动) | | 具体实例 | 已有 seed 类规则才排;`runtime_only` 不排;本步不改合同(已审查,基本不动) |
| 叙事指南与故事推进 | 转述仍不知遣词/禁忌/推进才排;默认:长自然段;用户输入=大纲扩写不越界 | | 叙事指南与故事推进 | 工序编排:转述仍不知遣词/禁忌/推进才排 |
| 叙事指南 | 对话落盘:缺怎么写(遣词/笔墨/场合换档)才排;推进已钉不构成不排 |
| 故事推进 | 对话落盘:缺输入用法或推进方式才排;文风未钉不构成不排 |
| 拓扑图谱 | 必须生成且不适合走生成规则→实例;用拓扑结构描述(升级路径、地图、人物关系等) | | 拓扑图谱 | 必须生成且不适合走生成规则→实例;用拓扑结构描述(升级路径、地图、人物关系等) |
| 变量设计与更新规则 | 必须跨轮记住否则会糊才排(已审查,用户确认完成) | | 变量设计与更新规则 | 必须跨轮记住否则会糊才排(已审查,用户确认完成) |
| 变量控制上下文 | 有信息差才排;全员同可见则可不排(已审查,用户确认完成) | | 变量控制上下文 | 有信息差才排;全员同可见则可不排(已审查,用户确认完成) |
+3 -2
View File
@@ -60,13 +60,14 @@
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
生成规则正文:必要性判断 + `rules[]`(设计判断 + 生成与描写含**配方/表现** + **分级表现** + **单层**产物格式 + 可选池:**方向池**以方向概括为主、例子极少,**元素池**仅封闭集合,旁观抽样只抽元素池 + **数量**与**一次生成**);自评=必要性/属性妥当/材料得当/格式准确。 生成规则正文:必要性判断 + `rules[]`(设计判断 + 生成与描写含**配方/表现** + **分级表现** + **单层**产物格式 + 可选池:**方向池**以方向概括为主、例子极少,**元素池**仅封闭集合,旁观抽样只抽元素池 + **数量**与**一次生成**);自评=必要性/属性妥当/材料得当/格式准确。
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。 美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。 叙事指南与故事推进(工序编排)正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层。artifact=`设计.叙事指南与故事推进`。
对话落盘拆成两份:`设计.叙事指南`(遣词/笔墨/禁忌/写法档)与 `设计.故事推进`(推进 + 用户输入用法);各自双挂,条件独立落盘。
回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。 回复呈现:每轮终稿给用户看什么(正文/字数/日期/变量);artifact=`设计.监控栏`(旧称设计监控栏/状态栏)。
正文组成:把回复呈现清单展示给用户(选壳、美化,可写 CSS/HTML/JS);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。 正文组成:把回复呈现清单展示给用户(选壳、美化,可写 CSS/HTML/JS);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。 变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。
变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。 变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。
生成规则:合同挂主世界层 + 旁观(旁观读 rule_id/格式/必要性,描写长文可 summary)。 生成规则:合同挂主世界层 + 旁观(旁观读 rule_id/格式/必要性,描写长文可 summary)。
开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。 开场白与开场变量:每条开场 = meta(用户角色+初值)+ 正文(present.v1,壳与正文组成一致);可经 opening-generator 落库。
具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。 具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。
+1 -1
View File
@@ -47,7 +47,7 @@ Book 跨 Session:过程、资产、游玩
固定上下文 tag 的主收益是 **跨执行单元复用同一份正文**;写下游时仍依赖上游定稿(不能只报 tag 名省掉正文)。能省的是扯皮过程(验收折叠)。 固定上下文 tag 的主收益是 **跨执行单元复用同一份正文**;写下游时仍依赖上游定稿(不能只报 tag 名省掉正文)。能省的是扯皮过程(验收折叠)。
不再要求用户选择 skill 包;默认 orchestrator 见 `src/config/default-orchestrator.ts`。 不再要求用户选择 skill 包;默认 orchestrator 见 `src/config/default-orchestrator.ts`。
方法细节见 **`design-orchestrator-guide.md` §7.2**。不要以「世界模拟器」为默认总形态。 方法细节见 **`design-orchestrator-guide.md` §7.2**。不要以「回合推演 / 世界模拟」为默认总形态。
### 进入游玩 ### 进入游玩
+4 -3
View File
@@ -15,7 +15,7 @@
|---|------| |---|------|
| 1 | 日常包含 **两类**:AIRP/交互扮演 **与** 长文/爽文创作(同一运行内核) | | 1 | 日常包含 **两类**:AIRP/交互扮演 **与** 长文/爽文创作(同一运行内核) |
| 2 | **UI 不自研美学**:布局/控件直接抄参考产品即可;**存档与续作稳定是硬指标** | | 2 | **UI 不自研美学**:布局/控件直接抄参考产品即可;**存档与续作稳定是硬指标** |
| 3 | **配方**:新建时用 UI **选一个**(世界模拟器 / 扩写助手…;初期可只有 1 项) | | 3 | **进料方式 + 配方**:新建时先选进料方式(工序编排 / 对话落盘),再选对应配方(回合推演 / 快穿短局 / 扩写助手…) |
| 4 | **工作流计划动态**:不在 UI 里点选死板「流程菜单」;选定配方后,由编排器听用户说话谈出本局工作流计划(创作流程)与运行规格(Worker 集)并调度 | | 4 | **工作流计划动态**:不在 UI 里点选死板「流程菜单」;选定配方后,由编排器听用户说话谈出本局工作流计划(创作流程)与运行规格(Worker 集)并调度 |
用户侧术语权威对照:**`ui-glossary.md` §0**(配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元)。 用户侧术语权威对照:**`ui-glossary.md` §0**(配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元)。
@@ -23,9 +23,10 @@
### 0.2 配方 vs 工作流计划 / 运行规格(动态) ### 0.2 配方 vs 工作流计划 / 运行规格(动态)
```text ```text
配方 = 用户 UI 选一次的方法起点(可调味) 进料方式 = 新建第一层:工序编排(工作流计划)或对话落盘(对话整理产物)
配方 = 该方式下再选一次的方法起点(可调味)
= 内部 recipes/ = 内部 recipes/
= 例:世界模拟器、扩写助手 = 例:回合推演、快穿短局、扩写助手
工作流计划 ≠ 再选一张固定流程卡 工作流计划 ≠ 再选一张固定流程卡
= 选定配方之后:用户说话 → 编排器调度创作 = 选定配方之后:用户说话 → 编排器调度创作
+1
View File
@@ -95,6 +95,7 @@
设计.正文组成 → 壳 + 美化(怎么展示) 设计.正文组成 → 壳 + 美化(怎么展示)
输出.用户展示(主世界正文)→ 转述提取/改写后的 body;suggested_actions → 行动区 输出.用户展示(主世界正文)→ 转述提取/改写后的 body;suggested_actions → 行动区
输出.用户展示(终稿)→ present.v1 或纯 Markdown(→ body) 输出.用户展示(终稿)→ present.v1 或纯 Markdown(→ body)
输出.开场白 → 与终稿同形的 present.v1(shell 跟正文组成);旧稿散文仍可回退为 body
``` ```
## 5. 游玩灌数契约 ## 5. 游玩灌数契约
+1 -1
View File
@@ -122,7 +122,7 @@ Progressive 投影 本轮主世界层只看见「当前档」;未
| **生成规则 / 具体实例** | 分档表 schema 与行数据(如好感→性格);生成后由变量设计索引 | | **生成规则 / 具体实例** | 分档表 schema 与行数据(如好感→性格);生成后由变量设计索引 |
| **正文组成** | 隐藏段定界(供解析维护语句);用户可见块序 | | **正文组成** | 隐藏段定界(供解析维护语句);用户可见块序 |
| **设计监控栏** | 用户可见的会变字段子集 | | **设计监控栏** | 用户可见的会变字段子集 |
| **开场白与开场变量** | 开场正文(守版式)+ 与开场同真相的初值 | | **开场白与开场变量** | 开场正文(present.v1,跟正文组成)+ 与开场同真相的初值 / 用户角色 |
| **游玩拓扑 / 上下文投影排序 / 细化终稿** | 固定槽;扁平投影序;tables 收成 | | **游玩拓扑 / 上下文投影排序 / 细化终稿** | 固定槽;扁平投影序;tables 收成 |
编排口诀:体验与机制 → 要不要真值 → 映射表用生成规则/实例 → 维护语句与旁观汇总 → 开场同真相 → 收成。 编排口诀:体验与机制 → 要不要真值 → 映射表用生成规则/实例 → 维护语句与旁观汇总 → 开场同真相 → 收成。
+47 -7
View File
@@ -4,7 +4,7 @@
**实现**:`src/server/display-labels.ts`(权威映射);`web/display-labels.js` 须与本文及该文件保持一致。新增高频 id 时:**先改本文 → 再改代码**。 **实现**:`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` | 导演(调度)、总管 | | **编排器** | Orchestrator / Supervisor | 会话里负责调度的 Agent:决定下一步调谁、是否追问或结束 | Main Agent / `orchestrator.md` | 导演(调度)、总管 |
| **技能** | Skill / Capability module | 共用工序模块(美学纲领与交互范式…);编排器从池里选型编排 | `modules/` | 能力、组件池(作者文档可写模块) | | **技能** | Skill / Capability module | 共用工序模块(美学纲领与交互范式…);编排器从池里选型编排 | `modules/` | 能力、组件池(作者文档可写模块) |
| **工作流计划** | Workflow Plan / Horizon DAG | 本局谈成的**近期**增量有向无环图(步骤 = 技能实例);可追加、拆分、复用、修订 | `设计.创作流程` | 剧本(流程部分)、死板流程卡 | | **工作流计划** | Workflow Plan / Horizon DAG | 本局谈成的**近期**增量有向无环图(步骤 = 技能实例);可追加、拆分、复用、修订 | `设计.创作流程` | 剧本(流程部分)、死板流程卡 |
@@ -24,16 +25,21 @@
关系口诀: 关系口诀:
```text ```text
用户选【配方】(世界模拟器 / 扩写助手 …) 用户先选【进料方式】(工序编排 / 对话落盘)
│ │
▼ ▼
【编排器】调度 → 从【技能】里编排 → 谈成【工作流计划】→ 收成【运行规格】 再选【配方】(回合推演、快穿短局、扩写助手 …)
│
▼
【编排器】调度 → 从【技能】里编排(或对话落盘直接整理产物)
→ 谈成【工作流计划】(工序编排)或落盘产物(对话落盘)
→ 收成【运行规格】
│ │
▼ ▼
【执行单元】按规格上场 → 游玩 / 成稿(运行实例) 【执行单元】按规格上场 → 游玩 / 成稿(运行实例)
``` ```
**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。 **两层、各选一次**:新建作品先选**进料方式**,再选该方式下的**配方**。不要叠「能力包 + 配方」,也不要把进料方式叫成某个配方的名字。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改:在图上点该节点重进(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。 **调味**:配方给出的建议步骤是**近期起点**(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. 原则
1. **内外分离**:id 给机器;中文给用户。 1. **内外分离**:id 给机器;中文给用户。
@@ -81,7 +114,8 @@
| Agent / Main Agent / orchestrator | **编排器** | 导演、总管(旧) | 调度执行单元、推进工作流计划;气泡可用「编排器 · 思考」。 | | Agent / Main Agent / orchestrator | **编排器** | 导演、总管(旧) | 调度执行单元、推进工作流计划;气泡可用「编排器 · 思考」。 |
| Worker | **执行单元** | 演员(旧) | UI 标题用中文名;检查器可写执行单元。 | | Worker | **执行单元** | 演员(旧) | UI 标题用中文名;检查器可写执行单元。 |
| Tool | **工具** | Tool | 编排器 tool call;气泡可用「工具 · 读黑板」。 | | Tool | **工具** | Tool | 编排器 tool call;气泡可用「工具 · 读黑板」。 |
| recipe(用户选型) | **配方**(选项名) | 导演、能力包(旧) | 新建下拉只出现配方选项(世界模拟器、扩写助手…)。 | | `creationMode` | **进料方式** | Worker 式、Boss直聘(旧) | 新建第一层:工序编排 / 对话落盘。 |
| recipe(用户选型) | **配方**(选项名) | 导演、能力包(旧) | 新建第二层:该进料方式下的配方(回合推演、快穿短局、扩写助手…)。 |
| Module / modules pool | **技能** | 能力、组件池(旧) | 见 `world-simulator-modules.md`。 | | Module / modules pool | **技能** | 能力、组件池(旧) | 见 `world-simulator-modules.md`。 |
| `设计.创作流程` | **工作流计划** | 剧本(旧,流程部分) | 增量 DAG;非死选单。 | | `设计.创作流程` | **工作流计划** | 剧本(旧,流程部分) | 增量 DAG;非死选单。 |
| `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 | | `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 |
@@ -101,6 +135,8 @@
| `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) | | `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) |
| `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 | | `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 |
| `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 | | `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 |
| `opening-setup`(技能) | **开场白与开场变量** | 每条开场 = meta(用户角色+初值)+ 正文(present.v1,壳跟正文组成)。游玩辅助 Tab「开场」点选;没有或不采用则用当前用户角色卡 |
| `protagonist`(技能) | **主角设定** | 旧路径兜底;新开场把用户角色写在开场白 meta 里 |
| `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 | | `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 |
| `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 | | `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 |
@@ -128,7 +164,7 @@
| `chance` | **机遇裁定**(主世界层 harness 批量工具;非每轮独立 LLM) | | `chance` | **机遇裁定**(主世界层 harness 批量工具;非每轮独立 LLM) |
| `round-present` | **回合呈现** | | `round-present` | **回合呈现** |
配方选项展示名示例:`world-simulator`(recipe)→ **世界模拟器**;`expand-assistant` → **扩写助手**。 配方选项展示名示例:`world-simulator`(recipe)→ **回合推演**(旧称世界模拟器);`快穿短局` → **快穿短局**;`expand-assistant` → **扩写助手**。
**创造执行单元时**:规格里另写 `name`(中文展示名)。`ref` 仍用英文 kebab;UI 优先 `name`。 **创造执行单元时**:规格里另写 `name`(中文展示名)。`ref` 仍用英文 kebab;UI 优先 `name`。
@@ -169,9 +205,12 @@
| `aesthetics-interaction` | 美学纲领与交互范式 | | `aesthetics-interaction` | 美学纲领与交互范式 |
| `interaction` | 交互范式(旧;已并入上一行) | | `interaction` | 交互范式(旧;已并入上一行) |
| `narrative_guide` | 叙事指南与故事推进 | | `narrative_guide` | 叙事指南与故事推进 |
| `narrative-guide` | 叙事指南(对话落盘) |
| `story-progression` | 故事推进(对话落盘) |
| `input_protocol` | 输入协议 | | `input_protocol` | 输入协议 |
| `core_premise` | 核心前提 | | `core_premise` | 核心前提 |
| `aesthetics` | 美学纲领(旧;已并入美学纲领与交互范式) | | `aesthetics` | 美学纲领(旧;已并入美学纲领与交互范式) |
| `protagonist` | 主角设定 |
--- ---
@@ -200,6 +239,7 @@
- 新建作品同时出现「导演 + 配方」或「能力包 + 配方」两层选择 - 新建作品同时出现「导演 + 配方」或「能力包 + 配方」两层选择
- 询问卡标题直接写 `design-core` - 询问卡标题直接写 `design-core`
- 新文案继续使用拍摄词「导演 / 剧本 / 演员 / 能力」作首选 - 新文案继续使用拍摄词「导演 / 剧本 / 演员 / 能力」作首选
- 用「世界模拟器」「Boss直聘 / Boss 直聘」称呼进料方式;用「Worker 式」作用户可见进料名
技术日志、导出里的「调试附录」、开发者文档不受本条限制,但默认导出给用户的 Markdown 应走同一套映射。 技术日志、导出里的「调试附录」、开发者文档不受本条限制,但默认导出给用户的 Markdown 应走同一套映射。
+9 -5
View File
@@ -1,7 +1,7 @@
# 世界模拟器 · 编排器与能力撰写清单 # 默认技能包 · 编排器与能力撰写清单
> 给作者用。用户侧术语:**`docs/ui-glossary.md` §0**。 > 包内部 id 仍为 `world-simulator`。用户可见:进料方式「工序编排 / 对话落盘」,配方「回合推演 / 快穿短局 …」——见 **`docs/ui-glossary.md` §0 / §0.1**。
> 运行:选配方 → 编排**增量**工作流计划 DAG → `design-step` 执行技能;可再扩步反复调用。 > 运行:选进料方式与配方 →(工序编排)编排**增量**工作流计划 DAG → `design-step` 执行技能;可再扩步反复调用。
> **标准范例**:`modules/aesthetics-interaction/prompt.md`。 > **标准范例**:`modules/aesthetics-interaction/prompt.md`。
> **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。 > **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。
> **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。 > **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。
@@ -94,6 +94,7 @@ boundary: …
| `closer` | 可选;`true` = 创作终节点(选定后落库、关 DAG、保存定稿) | | `closer` | 可选;`true` = 创作终节点(选定后落库、关 DAG、保存定稿) |
| `auto` | 可选;`true` = 程序步(提案后直接执行) | | `auto` | 可选;`true` = 程序步(提案后直接执行) |
| `kind` | 可选;节点特性。缺省=普通执行步。`prior-artifact` = **先验产物**(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) | | `kind` | 可选;节点特性。缺省=普通执行步。`prior-artifact` = **先验产物**(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) |
| `intake` | 可选;`recipe` = 仅工序编排;`dictate` = 仅对话落盘。缺省=两族都可见 |
| `params` | 可选;普通节点=编排期 `steps[].params` 声明(必填项须在进执行前钉齐)。〔先验产物〕=规划产物字段(可提前填,空则步内钉) | | `params` | 可选;普通节点=编排期 `steps[].params` 声明(必填项须在进执行前钉齐)。〔先验产物〕=规划产物字段(可提前填,空则步内钉) |
| `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 | | `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 |
@@ -171,18 +172,21 @@ recipes/world-simulator|expand-assistant/recipe.yaml
| 能力 | id | 状态 | | 能力 | id | 状态 |
|------|-----|------| |------|-----|------|
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 | | 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
| 主角设定 | `protagonist` | **已写**:有产物则挂在当前选用的用户角色卡下(不进全局列表);关掉这局就不加载 |
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** | | 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** | | 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** | | 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;方向池优先、元素池仅封闭集合;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` | | 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
| 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 | | 叙事指南与故事推进 | `narrative` | **工序编排**:一份全文双挂;结构化卡。对话落盘不排本步 |
| 叙事指南 | `narrative-guide` | **对话落盘**:遣词/笔墨/禁忌/写法档;`intake: dictate` |
| 故事推进 | `story-progression` | **对话落盘**:推进方式 + 用户输入用法;`intake: dictate` |
| 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) | | 拓扑图谱 | `topology` | 必须生成且不适合走生成规则→实例时,用拓扑结构写出(升级路径、地图、人物关系等) |
| 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 | | 回复呈现 | `status-bar` | **已写**:每轮终稿看什么(正文/字数/日期/变量);旧称设计监控栏 |
| 随机范围整理 | `random-range` | **已写**:汇总检定/对抗等随机项与范围;正文组成之前;替代机遇裁定槽 | | 随机范围整理 | `random-range` | **已写**:汇总检定/对抗等随机项与范围;正文组成之前;替代机遇裁定槽 |
| 正文组成 | `reply-format` | **已写**:把回复呈现清单展示出来;选壳+美化,可写 CSS/HTML/JS;可接隐藏备用随机区 | | 正文组成 | `reply-format` | **已写**:把回复呈现清单展示出来;选壳+美化,可写 CSS/HTML/JS;可接隐藏备用随机区 |
| 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 | | 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 |
| 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 | | 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 |
| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值;**〔收口〕**选定后落库并保存定稿 | | 开场白与开场变量 | `opening-setup` | **已写**:每条 = meta(初值/用户角色)+ present.v1 正文(壳跟正文组成);**〔收口〕**选定后落库并保存定稿 |
共用收成(池内保留,按需): 共用收成(池内保留,按需):
@@ -17,11 +17,12 @@ when: |
无上游,通常作第一步。 无上游,通常作第一步。
when_not: | when_not: |
这三件已有等价定稿、用户未要求重谈 → 不重复排入。 这三件已有等价定稿、用户未要求重谈 → 不重复排入。
只改文风 → 「叙事指南与故事推进」;只改每轮看什么 → 「回复呈现」;只改排版/壳 → 「正文组成」。 只改文风 → 「叙事指南」(工序编排:「叙事指南与故事推进」);只改推进或输入用法 → 「故事推进」(工序编排同上);只改每轮看什么 → 「回复呈现」;只改排版/壳 → 「正文组成」。
只改舞台、规则、真值、槽位 → 对应下游技能。与「给玩家什么体验」无关 → 不排。 只改舞台、规则、真值、槽位 → 对应下游技能。与「给玩家什么体验」无关 → 不排。
boundary: | boundary: |
本技能:体验是什么、怎么发生在用户身上、轮转怎么停。产出供后续编排阅读的开头节点。 本技能:体验是什么、怎么发生在用户身上、轮转怎么停。产出供后续编排阅读的开头节点。
叙事指南与故事推进:把感觉落成遣词/笔墨/禁忌与推进——本步只钉感觉与轮转。 叙事指南:把感觉落成遣词/笔墨/禁忌(对话落盘)——本步只钉感觉与轮转。工序编排二者并入「叙事指南与故事推进」。
故事推进:怎么往前、输入用法(对话落盘)——本步只钉感觉与轮转。
舞台骨架 / 具体实例 / 生成规则:舞台与可执行规则——本步不定。 舞台骨架 / 具体实例 / 生成规则:舞台与可执行规则——本步不定。
实现机制 / 变量* / 回复呈现 / 正文组成 / 游玩拓扑 / 上下文投影排序 / 细化终稿:落到可运行结构——本步不定。 实现机制 / 变量* / 回复呈现 / 正文组成 / 游玩拓扑 / 上下文投影排序 / 细化终稿:落到可运行结构——本步不定。
feeds: design_only feeds: design_only
@@ -9,6 +9,7 @@
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿 # closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收) # auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收)
# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行) # kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行)
# intake = 可选;recipe=仅工序编排 DAG;dictate=仅对话落盘何时落盘。缺省=两族都可见
# params = 可选;普通节点=编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐) # params = 可选;普通节点=编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐)
# 〔先验产物〕=规划产物字段(可提前填,空则步内钉) # 〔先验产物〕=规划产物字段(可提前填,空则步内钉)
# opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md) # opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md)
@@ -35,6 +36,13 @@ modules:
询问相近故合为一步。 询问相近故合为一步。
artifact: 设计.美学纲领与交互范式 artifact: 设计.美学纲领与交互范式
- id: protagonist
name: 主角设定
declaration: >-
钉清 @玩家 扮演的那个人:名字、背景、特殊设定(身份/金手指等)。
仅当开场白 meta 不够用时才排;用户角色优先写在开场白里。
artifact: 设计.主角设定
# —— 世界与内容 —— # —— 世界与内容 ——
- id: world-blueprint - id: world-blueprint
name: 舞台骨架 name: 舞台骨架
@@ -82,11 +90,28 @@ modules:
- id: narrative - id: narrative
name: 叙事指南与故事推进 name: 叙事指南与故事推进
intake: recipe
declaration: >- declaration: >-
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式; 美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与推进方式;
不重做体验问卷 不重做体验问卷。工序编排用本步;对话落盘拆成「叙事指南」「故事推进」
artifact: 设计.叙事指南与故事推进 artifact: 设计.叙事指南与故事推进
- id: narrative-guide
name: 叙事指南
intake: dictate
declaration: >-
美学已有方向时:把感觉落成可执行的遣词、笔墨落点、禁忌与写法档;
不重做体验问卷,也不钉故事怎么往前
artifact: 设计.叙事指南
- id: story-progression
name: 故事推进
intake: dictate
declaration: >-
美学已有方向时:钉故事怎么往前、用户输入用法(大纲扩写或扮演停笔);
不重做文风问卷
artifact: 设计.故事推进
# —— 机制与数据 —— # —— 机制与数据 ——
- id: mechanism - id: mechanism
name: 实现机制 name: 实现机制
@@ -143,7 +168,7 @@ modules:
name: 开场白与开场变量 name: 开场白与开场变量
closer: true closer: true
declaration: >- declaration: >-
创作终节点:写出 1~多条开场白(正文须含字面 @玩家)并钉同真相初值; 创作终节点:写出 1~多条开场(每条 = meta 用户角色 + 正文;须含字面 @玩家)并钉同真相初值;
选定后程序落库、结束创作并保存定稿 选定后程序落库、结束创作并保存定稿
artifact: 设计.开场白与开场变量 artifact: 设计.开场白与开场变量
@@ -56,7 +56,7 @@ feeds: design_only
2. 禁止重写上游长文;只写顺序与投影。 2. 禁止重写上游长文;只写顺序与投影。
3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。 3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。 4. 主世界层必须含 ref=`对话.历史`(默认 projection=summary);转述与旁观维护默认不含历史。
5. 主世界层应双挂「设计.叙事指南与故事推进」等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。 5. 主世界层应挂「设计.叙事指南」「设计.故事推进」(工序编排则为「设计.叙事指南与故事推进」)等 mount 到 gm 的块;有「设计.随机范围整理」则挂 gm(summary 即可);转述只读本轮正文(输出.用户展示)+ 文风/壳。
6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。 6. 旁观维护在回合末:须含「输出.用户展示」与变量合同,不含历史。
7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。 7. 越稳越靠前;本轮输入/真值/正文通常在历史之后。
8. 同一内容可挂多槽。 8. 同一内容可挂多槽。
@@ -21,11 +21,12 @@ when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 整体感觉未钉 → 先「美学纲领与交互范式」。
常识能推、也不需要精准格式(现实名车、西幻常见种族)→ 不排。 常识能推、也不需要精准格式(现实名车、西幻常见种族)→ 不排。
只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。 只要几张具名名片、不必按规则生成 → 不排;已有规则再要名片 → 「具体实例」。
跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「叙事指南与故事推进」。 跨轮记住什么 → 「变量设计与更新规则」。故事怎么往前写 → 「故事推进」(工序编排:「叙事指南与故事推进」)。
boundary: | boundary: |
本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。 本技能:一条规则=怎么描写/生成 + 单层 JSON 产物格式 + 可选池(方向池优先;元素池仅封闭集合)。
具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。 具体实例:按规则填最终 records;本步不写最终实例(池≠实例)。
舞台骨架 / 实现机制:可引用,不重写百科、不重钉既成法则。 舞台骨架 / 实现机制:上游真值只引用——`依据`/`硬约束`写短引用句或最短填写条款;社会格局、世界状况、既成法则正文不在本步重写。
缺上游真值时:本步仍只写填写合同时必须守的最短条款;格局/法则长文交给舞台骨架或实现机制,本步不代写百科、不发明「共通前提」迷你世界书。
变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。 变量*:游玩期状态更新——本步只约束生成时的类型与合法初值范围。
随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。 随机范围整理:把本步(及其他上游)里需要的掷骰/检定范围收成插入表——本步可写合法区间,不代替那份汇总。
旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。 旁观维护:可读合同(rule_id、产物格式、何时可 need_generate);程序抽样只抽元素池;方向池由填写方按规则自创。
@@ -43,7 +44,7 @@ feeds: gm,auditor
1. 生成出来必须钉死哪些字段吗?(如武功典籍的名字、效果、阶层)没有就说没有。 1. 生成出来必须钉死哪些字段吗?(如武功典籍的名字、效果、阶层)没有就说没有。
2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西? 2. 这是不是本局原创、听了会一头雾水、还要在故事里随机生成的东西?
3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成? 3. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。 4. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批,关系用一两句钉互指即可;彼此独立 → 一次一条。台上格局、世界律、前史不在这里展开。
5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。 5. 有没有必须全员一致、能数完的标签(阶层、强度档、武器种类)?名字、能力、气质、外貌这类写不完的,后面用「方向」划范围(一句话概括即可,少举例)。阶层这类光看名字不懂的,后面会写成能看见/听见的对照,不要只留阶名。
想到什么写什么;完整规则在正式产物里钉。 想到什么写什么;完整规则在正式产物里钉。
@@ -107,7 +108,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。 8. 几面拼成一格用 **配方**:点名目标字段、用到哪些方向组件、怎么拼进这一格。表现类要点用 **表现**:一句概括 + 至多 1 条例子;例子是密度样板,不是选项库。
9. 生命周期先行;未定前不假装可执行「具体实例」。 9. 生命周期先行;未定前不假装可执行「具体实例」。
10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。 10. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系 → `同批`;彼此独立 → `单条`。`同批` 至少 2 条。不要把「总共要几个」和「这一次交几条」混成一件事。
11. 不写变量更新公式、不写剧情推进、不填最终实例。 11. **上游真值引用,不重述**:舞台格局、世界状况、既成法则已有 → `依据`/`硬约束`点名引用;没有 → 只写最短填写条款,并在追问或开放问题标「应落舞台骨架/实现机制」。禁止用「共通前提」类长文重写世界书。
12. **同批关系只钉互指**:`一次生成.关系`与字段间约束写清谁对谁(对立/亲属/席位互指等);不借关系段落写前史、社会律、权力百科。
13. 不写变量更新公式、不写剧情推进、不填最终实例。
``` ```
## probe ## probe
@@ -115,9 +118,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```probe ```probe
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。 每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系;某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。 优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(只追互指,不追世界书);某字段该不该进表;不可穷举项有没有被错做成枚举词表;分级标签够不够让人看懂;描写字段地板是否够用。
若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。 若时机 a/b 都不成立 → 建议跳过,停追问字段与格式。
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、再给哪个字段加一套枚举池。 不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科、台上格局/既成法则长文(→ 舞台骨架/实现机制)、再给哪个字段加一套枚举池。
``` ```
## output ## output
@@ -345,4 +348,5 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。 - 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
- 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。 - 有亲属关系的三人却标成单条;或把「总共要 5 个」写成同批但彼此无必须同场的关系。
- 把写好的 NPC 整份塞进池当最终实例。 - 把写好的 NPC 整份塞进池当最终实例。
- 把社会格局、前史、既成律写成「共通前提」长文塞进规则;应引用舞台/机制,或只留最短填写硬约束。
``` ```
@@ -21,8 +21,10 @@ when_not: |
boundary: | boundary: |
本技能:点名故事当作既成事实的变造法则,写清如何约束后续;不补起源、不写舞台百科、不写人物名片。 本技能:点名故事当作既成事实的变造法则,写清如何约束后续;不补起源、不写舞台百科、不写人物名片。
美学纲领与交互范式:体验与轮转——本步不重定。 美学纲领与交互范式:体验与轮转——本步不重定。
舞台骨架:环境与社会格局。具体实例 / 开场白与开场变量:名片与第一屏(身份特殊走它们)。 舞台骨架:环境与社会格局(谁在台上、状况如何转)——本步只钉缺了会换骨架的既成律。
生成规则 / 变量* / 叙事指南与故事推进:持续生成、跨轮真值、遣词推进——本步只钉前提法则。 具体实例 / 开场白与开场变量:名片与第一屏(身份特殊走它们)。
生成规则:可引用本步法则作填写硬约束;本步不写对象族 schema/池,也不把法则扩成名片填写圣经。
变量* / 叙事指南 / 故事推进:跨轮真值、遣词、推进(工序编排后二者合为「叙事指南与故事推进」)——本步只钉前提法则。
feeds: gm feeds: gm
``` ```
@@ -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
好:一次钉清遣词 + 笔墨落点 + 禁忌;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。
坏:追问好感公式 / 事件池 / 大纲扩写还是扮演停笔;或把「当众折辱」「独处一室」当成情境备用。
```
@@ -3,6 +3,7 @@
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。 > 产物外壳:`docs/context-fragment-design.md`;对照美学:感觉 → 本步「怎么写、故事怎么往前」。
> **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。 > **一份产物、一次询问**;转述与主世界层都挂同一份(不必为省 token 拆成两套投影)。
> **工序编排用本步。** 对话落盘拆成「叙事指南」与「故事推进」,不要在落盘池排本步。
> 旧称:叙事指南。 > 旧称:叙事指南。
## meta ## meta
@@ -17,6 +18,7 @@ when: |
已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。 已有美学纲领(或等价),但转述/主世界层仍会不知道这四件:遣词什么味道、笔墨落谁身上、绝对不能写什么、故事靠场面自己推还是等用户。
用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。 用户主动谈文风、措辞禁忌、写法档(官能/爽文/恐怖/情色等)也排。
when_not: | when_not: |
本技能只给工序编排。对话落盘不排本步(落盘池是「叙事指南」与「故事推进」)。
美学未钉 → 先「美学纲领与交互范式」。 美学未钉 → 先「美学纲领与交互范式」。
用户只要感觉/站位、不要文风细则 → 不排,沿用美学。 用户只要感觉/站位、不要文风细则 → 不排,沿用美学。
版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。 版式/壳 → 「正文组成」;每轮看什么(字数/变量/日期)→ 「回复呈现」。
@@ -73,7 +75,7 @@ summary:`叙事指南与故事推进 · …`
- 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。 - 大纲扩写:输入=大纲→扩写为事实→细化后正文;不发明大纲未涉及的剧情,完整演绎大纲。
- 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。 - 扮演停笔:用户本轮行为与对话→正文开头并扩写填满;禁止描述用户角色未输入的其它行为/对话;禁止替用户角色做决定;正文停在其他角色需要用户角色回应之刻。
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。 4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
5. 整份双挂即可;禁止拆成两个技能。 5. 工序编排保持本步一份产物;对话落盘拆成「叙事指南」与「故事推进」,不要在本步执行时再拆技能。
6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。 6. 字数区间归「回复呈现」;本步管怎么写与怎么推,不重钉字数。
``` ```
@@ -2,7 +2,7 @@
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。 > 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。 > 产物外壳:`docs/context-fragment-design.md`。
> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场白供选定;选定后程序落库、关闭工作流、保存定稿。 > **创作终节点**:规格与呈现契约已齐。写出 1~多条开场(每条 = meta 初值/角色 + present.v1 正文)供选定;选定后程序落库、关闭工作流、保存定稿。
> 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。 > 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。
## meta ## meta
@@ -12,20 +12,21 @@ name: 开场白与开场变量
id: opening-setup id: opening-setup
artifact: 设计.开场白与开场变量 artifact: 设计.开场白与开场变量
declaration: > declaration: >
创作终节点:写出 1~多条开场白(须含字面 @玩家)并钉同真相初值;选定后落库、结束创作并保存定稿 创作终节点:写出 1~多条开场(每条 = meta 初值/用户角色 + 正文 present.v1,壳与正文组成一致;须含字面 @玩家);选定后落库、结束创作并保存定稿
when: | when: |
收成链已齐(至少体验契约可引用,且「细化终稿」已产出或可立即收成运行规格),需要可开玩的第一段剧情与同真相初值。 收成链已齐(至少体验契约可引用,且「细化终稿」已产出或可立即收成运行规格),需要可开玩的第一段剧情与同真相初值。
排在收成之后作最后一步。用户要进游玩但还没有第一屏 → 排。 排在收成之后作最后一步。用户要进游玩但还没有第一屏 → 排。
when_not: | when_not: |
体验/呈现仍混沌 → 先「美学纲领与交互范式」与「回复呈现」「正文组成」等。 体验/呈现仍混沌 → 先「美学纲领与交互范式」与「回复呈现」「正文组成」等。
只要改文风不要开场 → 「叙事指南与故事推进」。 只要改文风不要开场 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。
只要改真值规则不要开场文 → 「变量设计与更新规则」。 只要改真值规则不要开场文 → 「变量设计与更新规则」。
运行规格未齐 → 先「细化终稿」。尚未选定开场前若要补前序节点(如 NPC)→ 插在本步之前,勿以「收口已排入」拒绝追加。 运行规格未齐 → 先「细化终稿」。尚未选定开场前若要补前序节点(如 NPC)→ 插在本步之前,勿以「收口已排入」拒绝追加。
boundary: | boundary: |
本技能是创作终节点:1~多条开场正文(按回复呈现清单 + 正文组成展示)+ 开场变量初值。 本技能是创作终节点:1~多条开场。每条 = meta(用户角色名字+简介、开场变量初值)+ 正文(present.v1,shell 与「设计.正文组成」一致)。
必须遵守已验收的叙事指南、回复呈现、正文组成、变量设计;不重做这些契约。 用户角色跟这条开场走;不同开场可有不同用户角色。初值与该条叙述同真相。
必须遵守已验收的叙事指南、回复呈现、正文组成、变量设计;不重做这些契约。正文不得只交一段散文。
具体实例:开场可引用已有实例名片,不在本步新造百科。 具体实例:开场可引用已有实例名片,不在本步新造百科。
选定后由程序写入 输出.开场白 / 运行.初始变量 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。 选定后由程序写入 输出.开场白 / 运行.初始变量 / 运行.开场用户角色 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。
feeds: narrator feeds: narrator
``` ```
@@ -44,21 +45,31 @@ feeds: narrator
```task ```task
你正在执行「开场白与开场变量」。这是创作最后一步。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。 你正在执行「开场白与开场变量」。这是创作最后一步。产物必须是 **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 集/美学/叙事/正文组成/监控栏/变量设计/舞台与实例。 1. 读依赖:worker 集/美学/叙事/**正文组成(先抄 shell_id)**/监控栏/变量设计/舞台与实例。
2. 写 1~3 条开场候选(同真相、第一拍可不同)→ `开场白全文` + `开场白候选`(含 `@玩家`)。 2. 写 1~3 条候选:每条 `meta`(用户角色+开场变量)+ `正文`(present.v1)。同真相、第一拍可不同。
3. 按正文组成可见块填 `开场可见块`;隐藏段通常空。 3. 推荐条的 present.v1 同时写入 `开场白全文`(对象,不是字符串)。
4. 钉开场变量(与开场同真相;冲突以叙述为准)。 4. 顶层 `开场变量` = 推荐条 meta 里那份初值。冲突以叙述为准。
5. 若有监控栏:开场快照(短)。 5. 交稿前自检:每条 `正文.shell` 与正文组成一致;JSON 搜索 `@玩家`;再自评 + 追问;输出 JSON。
6. 交稿前自检:全文搜索确认每条候选含 `@玩家`;再自评 + 追问;输出 JSON。
缺上游契约时:能写则按已有材料写开场并标开放问题;不能成立第一拍则追问 1~2 点,勿交空壳。 缺上游契约时:能写则按已有材料写开场并标开放问题;不能成立第一拍则追问 1~2 点,勿交空壳。
@@ -69,9 +80,9 @@ summary:`开场白与开场变量 · …`(点题场景)。
```principles ```principles
1. 开场为主,表为配套。 1. 开场为主,表为配套。
2. 版式跟正文组成;文风跟叙事指南。 2. 正文 = present.v1,壳与「设计.正文组成」一致;文风跟叙事指南。尚未钉壳时用 prose 包一层 present.v1,并标开放问题。
3. 同真相:开场与开场变量一致。 3. 同真相:该条 meta.开场变量 与该条叙述一致。
4. 可行动:第一拍结束有下一步空间。 4. 可行动:第一拍结束有下一步空间;写入 suggested_actions(该壳开行动时)。
5. 短:愿点进游玩,非第一章。 5. 短:愿点进游玩,非第一章。
6. 不剧透隐藏档。 6. 不剧透隐藏档。
7. 用户角色名位 = 字面 `@玩家`(见 task 规格);不合格则改写。 7. 用户角色名位 = 字面 `@玩家`(见 task 规格);不合格则改写。
@@ -80,7 +91,7 @@ summary:`开场白与开场变量 · …`(点题场景)。
## probe ## probe
```probe ```probe
开场地点/冲突不清;某真值开场值推不出;版式块是否都要在开场出现;候选是否漏写 `@玩家`。一次 1~2 点。 开场地点/冲突不清;某真值开场值推不出;壳区域是否都要填;候选是否漏写 `@玩家` 或写成了裸散文。一次 1~2 点。
``` ```
## output ## output
@@ -94,19 +105,39 @@ summary:`开场白与开场变量 · …`(点题场景)。
"稳变": "stable", "稳变": "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, "分数": 8,
"说明": "是否遵循正文组成/叙事/监控栏" "说明": "是否 present.v1、shell 与正文组成一致、叙事/变量同真相"
}, },
{ {
"名": "同真相", "名": "同真相",
@@ -150,6 +176,11 @@ summary:`开场白与开场变量 · …`(点题场景)。
"名": "玩家占位", "名": "玩家占位",
"分数": 8, "分数": 8,
"说明": "每条候选是否含字面 @玩家、无写死姓名" "说明": "每条候选是否含字面 @玩家、无写死姓名"
},
{
"名": "版式符合",
"分数": 8,
"说明": "是否 present.v1,shell 与正文组成一致,区域未超出该壳"
} }
], ],
"薄弱点": "一句话" "薄弱点": "一句话"
@@ -169,25 +200,30 @@ summary:`开场白与开场变量 · …`(点题场景)。
``` ```
硬规则: 硬规则:
1. context-fragment.v1;自评含「玩家占位」。 1. context-fragment.v1;自评含「玩家占位」与「版式符合」。
2. `开场白全文` 与 `开场变量` 必填;`开场白候选` 1~3 条;监控栏无则为 `[]`。 2. `开场白全文` 与每条候选 `正文` 必须是 present.v1 对象(含 `schema`/`shell`/`blocks.body`);`shell` 与「设计.正文组成」一致。禁止只交散文字符串。
3. 每条开场候选正文必须含字面 `@玩家`;不发明新真值名。 3. `开场白候选` 1~3 条,每条含 `meta`(用户角色有代入则必填;开场变量与该条同真相)+ `正文`。顶层 `开场变量` 必填(推荐条初值)。
4. summary:`开场白与开场变量 · {brief 缩略}`。 4. 每条正文(含 body)必须含字面 `@玩家`;不发明新真值名。
5. summary:`开场白与开场变量 · {brief 缩略}`。
## checklist ## checklist
```checklist ```checklist
- [ ] 1~3 条候选,推荐条 = `开场白全文`? - [ ] 1~3 条候选,推荐条 正文 = `开场白全文`(均为 present.v1)?
- [ ] 每条候选全文可搜到 `@玩家`?无写死姓名? - [ ] `shell` 与「设计.正文组成」一致?只填该壳已有区域?不是裸散文?
- [ ] 版式与叙事口径?变量同真相?有行动空间? - [ ] 每条候选可搜到 `@玩家`?无写死姓名?
- [ ] 该条 meta 初值与叙述同真相?有行动空间?
- [ ] 未剧透隐藏档、未写成第一章? - [ ] 未剧透隐藏档、未写成第一章?
``` ```
## examples ## examples
```examples ```examples
好:「冷风灌进来时,@玩家还握着空水瓶。」(有字面 @玩家) 好:正文为 present.v1,shell 与正文组成相同;body 里有字面 @玩家;meta 带用户角色与开场变量。
好:「你(@玩家)站在闸机前,票夹是空的。」(你 + @玩家) 好:prose 壳也包成 {"schema":"present.v1","shell":"prose","blocks":{"body":"冷风灌进来时,@玩家还握着空水瓶。"}}。
好:「你(@玩家)站在闸机前,票夹是空的。」写在 blocks.body。
坏:`开场白全文` 只是一段字符串,没有 present.v1。
坏:shell 与正文组成不一致,或灌了该壳没有的区域。
坏:全文只有「你推开门」——零个 `@玩家`。 坏:全文只有「你推开门」——零个 `@玩家`。
坏:「林晚推开门」——写死姓名。 坏:「林晚推开门」——写死姓名。
``` ```
@@ -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
好:名字「林晚」;背景一句穿来当日处境;金手指「看见好感数字」+限制「别人看不见」。
坏:名字写成 @玩家;把侯府地理、朝堂势力写进背景;把开场羞辱戏全文写进名片。
```
@@ -50,7 +50,7 @@ boundary: |
- 设计.美学纲领与交互范式 → interaction / experience_check - 设计.美学纲领与交互范式 → interaction / experience_check
- 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots - 设计.worker规格(游玩拓扑)→ play_slots 或 writing_slots
- 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化 - 设计.上下文投影排序 → 写入规格的 context_order(每槽 inserts:order/anchor/ref/projection);无表且上下文很少时可按默认 static/dynamic 退化
- 设计.叙事指南与故事推进(旧称 设计.叙事指南)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序) - 设计.叙事指南 / 设计.故事推进(工序编排:设计.叙事指南与故事推进)/ 世界 / 机制 / 生成规则等 → resident_context(挂载以排序表与 mount 为准,本步不重排数字序)
- 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要) - 设计.变量设计与更新规则 → tables.side_effects(及 schemas 摘要)
- 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句 - 设计.变量控制上下文 → 核对挂载与剧透,写入 notes 或 resident 短句
@@ -21,7 +21,7 @@ when_not: |
还不知道每轮给用户看什么 → 先「回复呈现」。 还不知道每轮给用户看什么 → 先「回复呈现」。
已定要检定/掷骰但范围表未收 → 先「随机范围整理」。 已定要检定/掷骰但范围表未收 → 先「随机范围整理」。
不要排在开场白之后。几乎不跳过。 不要排在开场白之后。几乎不跳过。
只谈文风遣词 → 「叙事指南与故事推进」。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。 只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。只改呈现区域与内容规则(字数/选项槽/监控键)→ 「回复呈现」。
boundary: | boundary: |
本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式、美化排版(可含 CSS/HTML/JS);若有随机范围表,预留/接到隐藏备用随机区(用户看不见、发给 AI 仍在上下文)。 本技能:前端展示——从【可选呈现壳】选壳、把回复呈现的各区域落到版式、美化排版(可含 CSS/HTML/JS);若有随机范围表,预留/接到隐藏备用随机区(用户看不见、发给 AI 仍在上下文)。
回复呈现:必选/可选区域 + 各区简单内容规则——本步不重议清单与规则,只负责怎么显示。 回复呈现:必选/可选区域 + 各区简单内容规则——本步不重议清单与规则,只负责怎么显示。
@@ -19,7 +19,7 @@ when: |
when_not: | when_not: |
上游还没齐 → 先那些。 上游还没齐 → 先那些。
只谈怎么排版、选壳、CSS/HTML/JS → 「正文组成」。 只谈怎么排版、选壳、CSS/HTML/JS → 「正文组成」。
只谈文风遣词 → 「叙事指南与故事推进」。 只谈文风遣词 → 「叙事指南」(工序编排:「叙事指南与故事推进」)。
检定/掷骰范围汇总 → 「随机范围整理」。 检定/掷骰范围汇总 → 「随机范围整理」。
变量如何增减 → 「变量设计与更新规则」(本步只定展示哪些、展示规则)。 变量如何增减 → 「变量设计与更新规则」(本步只定展示哪些、展示规则)。
boundary: > boundary: >
@@ -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
好:推进写「等用户决策」;输入用法钉「扮演停笔」。
好:推进写「无专门推进」;输入用法钉「大纲扩写」,并写清不越界、一次成篇。
坏:把两套输入用法混成半套;追问好感公式 / 事件池;或在本步写遣词示范。
```
@@ -25,7 +25,7 @@ when_not: |
boundary: | boundary: |
本技能:用拓扑结构写出必须存在、又不走生成规则合同的内容(节点、连接、方向/层级)。 本技能:用拓扑结构写出必须存在、又不走生成规则合同的内容(节点、连接、方向/层级)。
生成规则 / 具体实例:复杂合同与 records——本步不写生成格式、不填名片表。 生成规则 / 具体实例:复杂合同与 records——本步不写生成格式、不填名片表。
舞台骨架:活动区域写细——本步不重写舞台百科。 舞台骨架:社会结构与活动区(谁在台上、区怎么划)——有节点与连接才走本步;仅格局叙述、无边可画 → 舞台骨架。
游玩拓扑:固定槽勾选——本步不描述执行单元谁读谁写。 游玩拓扑:固定槽勾选——本步不描述执行单元谁读谁写。
变量*:跨轮状态——本步不设计变量表。 变量*:跨轮状态——本步不设计变量表。
feeds: gm feeds: gm
@@ -65,6 +65,7 @@ summary:`拓扑图谱 · {名称或类型}`。
2. 拓扑很泛:升级路径、地图、人物关系都是同一类产物,不要只当成探索地图。 2. 拓扑很泛:升级路径、地图、人物关系都是同一类产物,不要只当成探索地图。
3. 节点短、连接准。点不是名片,连接不是散文,也不是生成合同。 3. 节点短、连接准。点不是名片,连接不是散文,也不是生成合同。
4. 该用规则反复生成的对象族不要塞进本步。 4. 该用规则反复生成的对象族不要塞进本步。
5. 仅有「谁在台上、如何分层」的格局叙述、画不出边 → 交给舞台骨架,本步不写。
``` ```
## probe ## probe
@@ -161,5 +162,6 @@ summary:`拓扑图谱 · {名称或类型}`。
好:功法一阶接到二阶、旁支接到某秘术——升级路径。 好:功法一阶接到二阶、旁支接到某秘术——升级路径。
坏:其实该按规则反复生成的对象族(如心之彩)硬写成拓扑。 坏:其实该按规则反复生成的对象族(如心之彩)硬写成拓扑。
坏:把舞台活动区再抄一遍,没有连接。 坏:把舞台活动区再抄一遍,没有连接。
坏:只有「谁在台上、如何分层」的格局散文、画不出边 → 舞台骨架,不是本步。
坏:描述主世界层谁读谁写 → 游玩拓扑,不是本步。 坏:描述主世界层谁读谁写 → 游玩拓扑,不是本步。
``` ```
@@ -28,7 +28,7 @@ boundary: |
不再设机遇裁定(chance)槽:真随机走「随机范围整理」+ 程序插入上下文备用数。 不再设机遇裁定(chance)槽:真随机走「随机范围整理」+ 程序插入上下文备用数。
细化终稿:按本步勾选展开 workers、合并常驻与 tables。 细化终稿:按本步勾选展开 workers、合并常驻与 tables。
变量设计 / 变量控制上下文:真值与投影,不是推理槽。 变量设计 / 变量控制上下文:真值与投影,不是推理槽。
叙事指南与故事推进:默认挂主世界层;若开了转述可挂转述(推进可兼挂主世界层);世界/机制:挂到主世界层——本步只点名槽。 叙事指南:默认挂主世界层;若开了转述可挂转述。故事推进挂主世界层(有转述可兼挂)。工序编排二者合为「叙事指南与故事推进」。世界/机制:挂到主世界层——本步只点名槽。
``` ```
## opening ## opening
@@ -118,7 +118,7 @@ boundary: |
}, },
"mount_notes": [ "mount_notes": [
"生成规则/变量合同/旁观摘要 → auditor(仅开旁观时;无长对话史)", "生成规则/变量合同/旁观摘要 → auditor(仅开旁观时;无长对话史)",
"叙事指南与故事推进 → gm(默认);若开转述可挂 narrator", "叙事指南 / 故事推进(工序编排合为叙事指南与故事推进)→ gm(默认);若开转述可挂 narrator",
"世界/机制/变量规则 → gm", "世界/机制/变量规则 → gm",
"随机范围整理 → gm(上下文备用随机数;无机遇槽)", "随机范围整理 → gm(上下文备用随机数;无机遇槽)",
"真值与 side_effects → 细化终稿 tables" "真值与 side_effects → 细化终稿 tables"
@@ -17,13 +17,15 @@ when: |
玩家实际游玩过程中的主要活动区域必须写细,但如果本身世界变造不多、或特殊设定较少(如西幻就足以概括的世界),就不需要多写。 玩家实际游玩过程中的主要活动区域必须写细,但如果本身世界变造不多、或特殊设定较少(如西幻就足以概括的世界),就不需要多写。
when_not: | when_not: |
整体感觉未钉 → 先「美学纲领与交互范式」。 整体感觉未钉 → 先「美学纲领与交互范式」。
代入后几乎不移动:玩家只会在一处待着体验,不会走到别的活动区域(如网聊、门卫室)→ 不排。 既少换区、社会格局与世界状况也不需要备案才能撑体验(如网聊、门卫室)→ 不排。
可点名名片 → 「具体实例」另排。持续生成合同 → 「生成规则」。 虽少换区,但台上势力/运转状况仍决定体验(如一室朝堂博弈)→ 仍排;活动区可极简成一处。
可点名名片 → 「具体实例」另排。对象族怎么生成 → 「生成规则」。
boundary: | boundary: |
本技能:舞台备案——范围、基底+变造、主要活动区域(写细)、社会结构、世界状况。 本技能:舞台备案——范围、基底+变造、主要活动区域(写细)、社会结构、世界状况。
多张名片/同批实例共享的台上格局与世界状况写在这里,供下游引用。
美学纲领与交互范式:体验与轮转——本步不重定。 美学纲领与交互范式:体验与轮转——本步不重定。
实现机制:既成变造法则——本步把法则落到活动区域上长什么样,不重钉法则来源。 实现机制:既成变造法则——本步把法则落到活动区域上长什么样,不重钉法则来源。
具体实例 / 生成规则 / 叙事指南与故事推进:名片、生成节奏、遣词与推进——本步不定。 具体实例 / 生成规则 / 叙事指南:名片、生成合同(schema/池/描写法)、遣词——本步不定。故事推进(工序编排并入「叙事指南与故事推进」)也不定。
feeds: gm feeds: gm
``` ```
@@ -83,6 +85,7 @@ summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况)
地理只是一类钩子。社会结构(谁在台上、如何分层互动)与世界状况(寒灾、异界出口、活人祭祀、命脉资源等) 地理只是一类钩子。社会结构(谁在台上、如何分层互动)与世界状况(寒灾、异界出口、活人祭祀、命脉资源等)
往往比「哪条河接哪座山」更能决定故事如何运转——必须写,但不能写成物种志/民俗全书。 往往比「哪条河接哪座山」更能决定故事如何运转——必须写,但不能写成物种志/民俗全书。
可以是抽象格局(对峙、割据、阶层天井)。不要求接壤比例尺、全史年表。 可以是抽象格局(对峙、割据、阶层天井)。不要求接壤比例尺、全史年表。
下游要生成多张名片时:共享的台上格局与状况仍写在本步;生成规则只引用,不在规则里重抄世界书。
下游需要点名条目时交给「具体实例」;本步给格局级与状况级引用钩子。 下游需要点名条目时交给「具体实例」;本步给格局级与状况级引用钩子。
3. 变造基底,再按活动区写细 3. 变造基底,再按活动区写细
@@ -269,5 +272,6 @@ summary:`舞台骨架 · …`(点题尺度 / 社会结构或世界状况)
- 只写山脉河流,不写祭祀/灾变/异界口等用户已点名的运转部件。 - 只写山脉河流,不写祭祀/灾变/异界口等用户已点名的运转部件。
- 「先写完整七大国、五十种魔族生态、三万年神话…」→ 百科。 - 「先写完整七大国、五十种魔族生态、三万年神话…」→ 百科。
- 「地点1:XX咖啡馆,店主叫…」→ 具体实例,不是骨架。 - 「地点1:XX咖啡馆,店主叫…」→ 具体实例,不是骨架。
- 把多张名片共享的台上格局丢给生成规则去写「共通前提」→ 本步该写格局钩子,规则只引用。
- 整份只有散文无三段外壳。 - 整份只有散文无三段外壳。
``` ```
@@ -1,4 +1,4 @@
# 节点流程 · 配方目录(与转述进料配方不共享) # 工序编排 · 配方目录(与对话落盘配方不共享)
# id = recipes/{id}/ 文件夹 # id = recipes/{id}/ 文件夹
# name = 固定中文名(下拉展示) # name = 固定中文名(下拉展示)
# declaration = 给人看的短说明 # declaration = 给人看的短说明
@@ -8,8 +8,8 @@
recipes: recipes:
- id: world-simulator - id: world-simulator
name: 世界模拟器 name: 回合推演
declaration: 回合互动、世界推进、角色扮演类体验的设计方法 declaration: 回合互动、可扩展的世界推进与角色扮演;适合要长期经营、能往外长设定的体验
- id: expand-assistant - id: expand-assistant
name: 扩写助手 name: 扩写助手
@@ -1,9 +1,9 @@
# 世界模拟器 · 配方 # 回合推演 · 配方(内部 id:world-simulator;旧称世界模拟器)
# 写方法论:适用、核心思路、设计流程、原则。 # 写方法论:适用、核心思路、设计流程、原则。
# 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。 # 各技能何时用 / 不用 → 读技能 meta(编排器会注入),勿在此重复。
# steps = 开局起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步。 # steps = 开局起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步。
when: 回合互动、世界推进、角色扮演、沉浸推演类体验 when: 回合互动、可扩展的世界推进、角色扮演、沉浸推演类体验
core: >- core: >-
先钉清用户如何参与、正文如何呈现、核心体验与禁忌(美学卡上用户会在意的部分); 先钉清用户如何参与、正文如何呈现、核心体验与禁忌(美学卡上用户会在意的部分);
@@ -32,7 +32,7 @@ principles:
- 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。实现机制对照其 meta。 - 舞台骨架通常都排(备案,只分详细度);代入后几乎不移动、只在一处待着则不排。实现机制对照其 meta。
- 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。 - 百科与查表归主世界层;可见正文默认由主世界层直接写;显式开转述时转述在主世界正文上改写/镶壳。
brief: 世界模拟类:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格 brief: 回合推演:美学卡上在意的部分→挂槽写上下文→晚排序投影→收成运行规格
steps: steps:
- id: 美学纲领与交互范式 - id: 美学纲领与交互范式
@@ -1,8 +1,11 @@
# 转述进料 · 配方目录(与节点流程配方不共享) # 对话落盘 · 配方目录(与工序编排配方不共享)
# id = recipes/转述/{id}/ 文件夹;对用户展示用 name(中文) # id = recipes/转述/{id}/ 文件夹;对用户展示用 name(中文)
# 选定后写入黑板 tag 创作.选用配方;转述模式不种节点 DAG # 选定后写入黑板 tag 创作.选用配方;对话落盘不种节点 DAG
recipes: recipes:
- id: 快穿短局
name: 快穿短局
declaration: 围着一个核心设定快速代入;难扩展、不适合长期经营;至多简单数值
- id: 数据化跑团体验 - id: 数据化跑团体验
name: 数据化跑团体验 name: 数据化跑团体验
declaration: AIRP / 交互式小说;设定为主,可选跨轮真值与分档映射 declaration: AIRP / 交互式小说;设定为主,可选跨轮真值与分档映射
@@ -1,4 +1,4 @@
# 交互式长文生成器 · Boss 直聘进料配方 # 交互式长文生成器 · 对话落盘配方
# 原「文本生成器」长文路径;多轮互动钉规格,再生成连贯长文。 # 原「文本生成器」长文路径;多轮互动钉规格,再生成连贯长文。
when: >- when: >-
@@ -10,8 +10,8 @@ core: >-
默认无变量、无旁观、无主世界层。正文格式(设计.正文组成)与开场白按需另钉。 默认无变量、无旁观、无主世界层。正文格式(设计.正文组成)与开场白按需另钉。
process: process:
- 听清写什么、给谁看、气质与禁忌 → insert 用户.需求 / 设计.文风 等(order 偏小)。缺关键信息就问一句。 - 听清写什么、给谁看、气质与禁忌 → insert 用户.需求 / 设计.文风 等(order 偏小)。本轮材料已够时可同轮多落;缺关键信息就问一句。
- 需要版式 → insert 设计.正文组成;要开篇样例 → 按格式 insert 设计.开场白。 - 需要版式 → insert 设计.正文组成;要开篇样例 → 按格式 insert 设计.开场白。上游已齐时可与上列同轮一并落。
- 不要搭跑团数值、舞台骨架、declare_variable。 - 不要搭跑团数值、舞台骨架、declare_variable。
- 长章叙事落产物,不要只留在聊天确认段。 - 长章叙事落产物,不要只留在聊天确认段。
@@ -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: []
@@ -1,4 +1,4 @@
# 数据化跑团体验 · Boss 直聘进料配方 # 数据化跑团体验 · 对话落盘配方
# 注入转述总助手「本局配方」;不种节点 DAG。 # 注入转述总助手「本局配方」;不种节点 DAG。
# 名字带「数据化」,内核仍是 AIRP / 交互式小说。 # 名字带「数据化」,内核仍是 AIRP / 交互式小说。
# 各能力何时落盘 → 系统提示【能力 · 何时落盘】(modules meta);本配方只给顺序骨架。 # 各能力何时落盘 → 系统提示【能力 · 何时落盘】(modules meta);本配方只给顺序骨架。
@@ -12,14 +12,15 @@ core: >-
再按需用核心规则(实现机制)钉下那套特殊故事设计;然后才往外兑现舞台、 再按需用核心规则(实现机制)钉下那套特殊故事设计;然后才往外兑现舞台、
叙事、真值、映射、版式与开场。作用对象是本局黑板;开玩后主世界层读投影与 叙事、真值、映射、版式与开场。作用对象是本局黑板;开玩后主世界层读投影与
真值写正文,默认只开主世界层、原文即终稿。 真值写正文,默认只开主世界层、原文即终稿。
具体某一刀能不能落:对照【能力 · 何时落盘】的何时用 / 何时不用;本配方不重复罗列。 具体能不能落:对照【能力 · 何时落盘】的何时用 / 何时不用;本配方不重复罗列。
process: process:
- 每轮先对照【能力 · 何时落盘】与已有产物:只推进「何时用」已成立的一刀;条件含糊或未到 → 短确认或问挡住的 1 点,不批量 insert / declare。 - 每轮对照【能力 · 何时落盘】与已有产物:凡「何时用」已成立的可同轮多次 insert / declare_*;条件含糊或未到 → 本轮不落该能力,短确认或问挡住的 1 点。不抢跑未齐条件的下游。
- 【骨架序 · 1】美学纲领与体验核心(能力表里通常总是先做)→ insert 设计.美学纲领与交互范式。模糊则细化;不确定则询问。未锚定前不建议下游。 - 【骨架序 · 1】美学纲领与体验核心(能力表里通常总是先做)→ insert 设计.美学纲领与交互范式。模糊则细化;不确定则询问。未锚定前不建议下游。
- 【骨架序 · 2】实现机制(仅当能力「何时用」成立:有拿掉就换骨架的变造法则)→ insert 设计.实现机制。何时不用成立则跳过,不追问要不要加规则。 - 【骨架序 · 2】实现机制(仅当能力「何时用」成立:有拿掉就换骨架的变造法则)→ insert 设计.实现机制。何时不用成立则跳过,不追问要不要加规则。
- 【骨架序 · 3+】其余能力(舞台、生成规则、叙事、变量、回复呈现、正文组成、开场白…)各自对照何时用再落;变量走 declare_variable / declare_map。一次一两刀。 - 【骨架序 · 3+】其余能力(主角设定、舞台、生成规则、叙事、变量、回复呈现、正文组成、开场白…)各自对照何时用再落;变量走 declare_variable / declare_map。本轮材料已够且条件齐的可一并落。
- 聊完一轮:短确认本轮落了什么 + 建议「条件已近」的下一刀(勿推荐尚未满足何时用的能力)。 用户代入且有名字/身份/金手指时落主角设定(insert 设计.主角设定)。
- 聊完一轮:按 reply_module 分段确认本轮所落模块 + 建议「条件已近」的下一刀(勿推荐尚未满足何时用的能力)。
principles: principles:
- 锚定优先:美学不定稳,不做下游;完善设定时不要忘掉核心体验。 - 锚定优先:美学不定稳,不做下游;完善设定时不要忘掉核心体验。
@@ -1,4 +1,4 @@
# 文本生成器 · Boss 直聘进料配方 # 文本生成器 · 对话落盘配方
# 创作期写出游玩要用的固定上下文;成文在游玩:主世界出正文,转述出文风/壳。 # 创作期写出游玩要用的固定上下文;成文在游玩:主世界出正文,转述出文风/壳。
when: >- when: >-
@@ -6,16 +6,17 @@ when: >-
篇与篇不必剧情承接;成文走游玩管线(主世界 + 叙事转述) 篇与篇不必剧情承接;成文走游玩管线(主世界 + 叙事转述)
core: >- core: >-
创作进料必须 insert 齐游玩上下文:模仿范例、模仿要点、叙事指南(大纲扩写)、可选轻量需求。 创作进料必须 insert 齐游玩上下文:模仿范例、模仿要点、故事推进(大纲扩写)、可选叙事指南与轻量需求。
落档只挂载这些创作期产物,不另造用法。开玩后主世界写故事正文(Markdown),转述按范例改写/镶壳。 落档只挂载这些创作期产物,不另造用法。开玩后主世界写故事正文(Markdown),转述按范例改写/镶壳。
刚需开转述;用户输入用法以创作期叙事指南为准。 刚需开转述;用户输入用法以创作期故事推进为准。
process: process:
- 要齐:① insert 设计.模仿范例;② insert 设计.模仿要点(用户点名要学的面)。缺则问这两项。 - 要齐:① insert 设计.模仿范例;② insert 设计.模仿要点(用户点名要学的面)。缺则问这两项。
- 必 insert 设计.叙事指南与故事推进(创作期产物,开玩直接挂载)。正文至少写清: - 必 insert 设计.故事推进(创作期产物,开玩直接挂载)。正文至少写清:
「用户输入用法:大纲扩写。本轮输入=短文大纲/场面要点;一次成完整篇并收束。禁止扮演停笔、禁止等用户行动。主世界交故事正文;有转述时转述负责文风与呈现,可补残稿但不改剧情。」 「用户输入用法:大纲扩写。本轮输入=短文大纲/场面要点;一次成完整篇并收束。禁止扮演停笔、禁止等用户行动。主世界交故事正文;有转述时转述负责文风与呈现,可补残稿但不改剧情。」
- 文风细则对照「叙事指南」何时用再 insert 设计.叙事指南。
- 可选 insert 用户.需求(题材/禁忌/篇幅)。 - 可选 insert 用户.需求(题材/禁忌/篇幅)。
- 齐料清单缺叙事指南 / 范例 / 要点任一 → 先补 insert,不要说可以开玩。 - 齐料清单缺故事推进 / 范例 / 要点任一 → 先补 insert,不要说可以开玩。
- 材料齐了:告知「可以落档/开玩」;禁止在创作期写成稿短文。 - 材料齐了:告知「可以落档/开玩」;禁止在创作期写成稿短文。
- 开玩:用户本轮=本篇大纲;读创作期已挂上下文推进。对话一长 clear_dialogue,留产物。 - 开玩:用户本轮=本篇大纲;读创作期已挂上下文推进。对话一长 clear_dialogue,留产物。
@@ -23,9 +24,9 @@ principles:
- 游玩只消费创作期产物;用法/文风/模仿面都在 insert 里,不靠落档程序补种。 - 游玩只消费创作期产物;用法/文风/模仿面都在 insert 里,不靠落档程序补种。
- 创作≠生产:创作收上下文;生产在游玩。 - 创作≠生产:创作收上下文;生产在游玩。
- 分工:主世界 = 故事正文;转述 = 文风/壳(对照范例+要点;可补残稿)。 - 分工:主世界 = 故事正文;转述 = 文风/壳(对照范例+要点;可补残稿)。
- 输入用法由创作期叙事指南钉死为大纲扩写。 - 输入用法由创作期故事推进钉死为大纲扩写。
- 一篇一世界;少问约束;无 declare_variable。 - 一篇一世界;少问约束;无 declare_variable。
brief: 文本生成:创作期钉范例+要点+大纲扩写指南 → 开玩挂载成文 brief: 文本生成:创作期钉范例+要点+大纲扩写推进 → 开玩挂载成文
steps: [] steps: []
@@ -8,6 +8,8 @@ when: 主世界层本轮正文已写入后
suggested_context: suggested_context:
static: static:
- 设计.叙事指南与故事推进 - 设计.叙事指南与故事推进
- 设计.叙事指南
- 设计.故事推进
- 设计.正文组成 - 设计.正文组成
- 设计.监控栏 - 设计.监控栏
dynamic: dynamic:
@@ -39,9 +41,9 @@ prompt_excerpt: |
monitor 用短键值(芯片),勿写长文。只填该壳已有区域。 monitor 用短键值(芯片),勿写长文。只填该壳已有区域。
用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。 用户主读正文(body)默认 1000~2000 字(汉字);以「设计.监控栏」回复呈现已钉字数为准。
采用完整长自然段叙述,不要单句成段;不要几句短信就结束。 采用完整长自然段叙述,不要单句成段;不要几句短信就结束。
用户输入用法以「设计.叙事指南与故事推进」为准: 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
- 大纲扩写:把主世界正文当事实底稿,细化文风后写完整终稿;不超出底稿未涉及的剧情;一次成篇,勿停等用户行动。 - 大纲扩写:把主世界正文当事实底稿,细化文风后写完整终稿;不超出底稿未涉及的剧情;一次成篇,勿停等用户行动。
- 扮演停笔:仅当叙事指南明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。 - 扮演停笔:仅当故事推进(或合并技能)明确钉了此用法时——终稿停在其他角色等用户角色回应之刻;不可写用户角色未给出的其它行为/对话。
- 未钉叙事指南时:按大纲扩写,禁止默认扮演停笔。 - 未钉故事推进/叙事指南时:按大纲扩写,禁止默认扮演停笔。
禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。 禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。
禁止编造主世界正文中没有的事实。 禁止编造主世界正文中没有的事实。
@@ -21,5 +21,5 @@ suggested_outputs:
- 运行.初始变量 - 运行.初始变量
- 变量.当前 - 变量.当前
prompt_excerpt: | prompt_excerpt: |
若有设计.开场白与开场变量:落库开场白全文与开场变量,遵守正文组成版式;保留字面 @玩家。 若有设计.开场白与开场变量:落库推荐条 present.v1 正文与开场变量;保留字面 @玩家。
若无:现写开场(正文须含 @玩家),表与开场同真相。禁止先填表再糊开场。 若无:现写开场(present.v1,shell 与正文组成一致;正文须含 @玩家),表与开场同真相。禁止先填表再糊开场。
@@ -11,6 +11,8 @@ suggested_context:
- 用户.需求 - 用户.需求
- 设计.变量设计与更新规则 - 设计.变量设计与更新规则
- 设计.叙事指南与故事推进 - 设计.叙事指南与故事推进
- 设计.叙事指南
- 设计.故事推进
- 设计.正文组成 - 设计.正文组成
- 设计.监控栏 - 设计.监控栏
dynamic: dynamic:
@@ -28,10 +30,10 @@ prompt_excerpt: |
无独立转述时:你写的正文就是用户终稿。 无独立转述时:你写的正文就是用户终稿。
若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。 若本局启用了叙事转述:你仍写完整(或尽量完整)的场面正文;文风润色、残稿补全、前端壳由转述处理。不要改成「只交事实包」。
真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。 真值/表字段变更不靠你交结构化包:有旁观维护时由副 LLM 根据本轮正文改表;若正文组成钉了隐藏段维护语句,可按约定写短键值,否则不要为维护打断叙述。
用户输入用法以「设计.叙事指南与故事推进」为准: 用户输入用法以「设计.故事推进」或「设计.叙事指南与故事推进」为准:
- 钉了「大纲扩写」:本轮输入=待扩写大纲/场面要点;一次交完整篇正文,勿停在等用户行动处。 - 钉了「大纲扩写」:本轮输入=待扩写大纲/场面要点;一次交完整篇正文,勿停在等用户行动处。
- 钉了「扮演停笔」:才只推进用户已声明行为/对话,并停在需用户回应之刻。 - 钉了「扮演停笔」:才只推进用户已声明行为/对话,并停在需用户回应之刻。
- 未钉叙事指南时:按大纲扩写处理,禁止默认扮演停笔。 - 未钉故事推进/叙事指南时:按大纲扩写处理,禁止默认扮演停笔。
需要随机数时:只用本轮上下文里程序已插入的备用随机数(见「设计.随机范围整理」);禁止口算编造。 需要随机数时:只用本轮上下文里程序已插入的备用随机数(见「设计.随机范围整理」);禁止口算编造。
不要把未投影的分档长文整表塞进输出;只写本轮可见后果。 不要把未投影的分档长文整表塞进输出;只写本轮可见后果。
禁止在正文里写出 tag 名、压缩摘要或过程日志。 禁止在正文里写出 tag 名、压缩摘要或过程日志。
@@ -42,11 +42,11 @@ contextSegments:
创作末尾兜底(DAG **没有**「开场白与开场变量」时才需要本 worker)。 创作末尾兜底(DAG **没有**「开场白与开场变量」时才需要本 worker)。
规格已定。优先读取 **`设计.开场白与开场变量`**: 规格已定。优先读取 **`设计.开场白与开场变量`**:
- 若已有且可用:把其中「开场白全文」写入 `输出.开场白`,把「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。微调时仍须保留字面 `@玩家`。 - 若已有且可用:把其中推荐条的 present.v1「正文」写入 `输出.开场白`(JSON 字符串),把该条 meta「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。微调时仍须保留字面 `@玩家`,且保持 present.v1。
- 若无:再站在 Worker 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。 - 若无:再站在 Worker 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。
```text ```text
主产物:输出.开场白(用户读的第一幕;须符合正文组成版式;须含字面 @玩家) 主产物:输出.开场白(用户读的第一幕;**present.v1**,shell 与正文组成一致;须含字面 @玩家)
辅产物:运行.初始变量 / 变量.当前(与开场同一真相) 辅产物:运行.初始变量 / 变量.当前(与开场同一真相)
``` ```
@@ -57,7 +57,7 @@ contextSegments:
你是开场落库者(兼必要时的开场作者): 你是开场落库者(兼必要时的开场作者):
1. 先读 `设计.开场白与开场变量`(若有),再读 Worker 集 / 定稿摘要 / 用户需求 1. 先读 `设计.开场白与开场变量`(若有),再读 Worker 集 / 定稿摘要 / 用户需求
2. **落库或写出开场白**:遵守叙事指南与正文组成;用户角色名位写字面 `@玩家` 2. **落库或写出开场白**:遵守叙事指南与正文组成;`输出.开场白` 必须是 present.v1 JSON(shell 与「设计.正文组成」一致);用户角色名位写字面 `@玩家`
3. **落表**:初值与开场事实一致;能推断则推断 3. **落表**:初值与开场事实一致;能推断则推断
## 重心(务必遵守) ## 重心(务必遵守)
@@ -132,7 +132,7 @@ contextSegments:
```json ```json
{ {
"outputs": { "outputs": {
"输出.开场白": "……须含字面 @玩家……", "输出.开场白": "{\"schema\":\"present.v1\",\"shell\":\"与正文组成一致\",\"blocks\":{\"body\":\"……须含字面 @玩家……\"},\"meta\":{\"suggested_actions\":[]}}",
"运行.初始变量": "{ ... rows ... }", "运行.初始变量": "{ ... rows ... }",
"变量.当前": "{ ... 与初始一致 ... }" "变量.当前": "{ ... 与初始一致 ... }"
}, },
+14
View File
@@ -102,6 +102,20 @@ export class Blackboard {
return item; 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 { seed(items: BlackboardItem[]): void {
for (const item of items) { for (const item of items) {
this.items.set(item.id, item); this.items.set(item.id, item);
+111 -1
View File
@@ -6,10 +6,13 @@ export type TableCellSource = `user` | `system` | `worker:${string}`;
export type TableCell = { export type TableCell = {
key: string; key: string;
value: unknown; value: unknown;
/** 该格版本号:手改须带读到的 rev,对不上则拒绝,避免盖过后端更新 */
rev: number; rev: number;
updatedAt: string; updatedAt: string;
source: TableCellSource | string; source: TableCellSource | string;
visibility?: "visible" | "hidden"; visibility?: "visible" | "hidden";
/** 未声明时:可见格可改,隐藏格只读 */
editable?: boolean;
note?: string; note?: string;
}; };
@@ -47,6 +50,12 @@ export function parseTableDoc(raw: string | undefined | null): TableDoc | null {
row.visibility === "hidden" || row.visibility === "visible" row.visibility === "hidden" || row.visibility === "visible"
? row.visibility ? row.visibility
: "visible", : "visible",
editable:
row.editable === true
? true
: row.editable === false
? false
: undefined,
note: typeof row.note === "string" ? row.note : undefined, note: typeof row.note === "string" ? row.note : undefined,
}); });
} }
@@ -109,6 +118,7 @@ export function mergeTableCells(params: {
updatedAt: now, updatedAt: now,
source: params.actor, source: params.actor,
visibility: patch.visibility ?? existing?.visibility ?? "visible", visibility: patch.visibility ?? existing?.visibility ?? "visible",
editable: patch.editable ?? existing?.editable,
note: patch.note ?? existing?.note, note: patch.note ?? existing?.note,
}); });
applied.push(key); 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 */ /** 从零创建:全部 source=actor,rev=1 */
export function createTableFromValues( export function createTableFromValues(
values: Record<string, unknown>, values: Record<string, unknown>,
actor: TableCellSource | string, actor: TableCellSource | string,
meta?: Record<string, { visibility?: "visible" | "hidden"; note?: string }>, meta?: Record<
string,
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
>,
): TableDoc { ): TableDoc {
const now = new Date().toISOString(); const now = new Date().toISOString();
const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({ const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({
@@ -135,6 +242,9 @@ export function createTableFromValues(
updatedAt: now, updatedAt: now,
source: actor, source: actor,
visibility: meta?.[key]?.visibility ?? "visible", visibility: meta?.[key]?.visibility ?? "visible",
...(meta?.[key]?.editable !== undefined
? { editable: meta[key]?.editable }
: {}),
note: meta?.[key]?.note, note: meta?.[key]?.note,
})); }));
return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) }; return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) };
+23
View File
@@ -19,6 +19,17 @@ export {
} from "./context.js"; } from "./context.js";
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
export { runDictateTurn, type DictateTurnResult } from "./turn.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 { export {
applyDictatePlayBind, applyDictatePlayBind,
bindDictateProductsToPlaySpec, bindDictateProductsToPlaySpec,
@@ -26,6 +37,18 @@ export {
collectDictateBindProducts, collectDictateBindProducts,
type DictatePlayBindResult, type DictatePlayBindResult,
} from "./play-bind.js"; } from "./play-bind.js";
export {
REPEATABLE_TAG_SEP,
buildRepeatableProductTag,
collectFamilyContents,
dictateProductFamily,
dictateProductSlot,
extractRepeatableSlotFromContent,
isDictateProductFamily,
mergeGenerationRulesArtifacts,
resolveRepeatableInsertTag,
sanitizeRepeatableSlot,
} from "./repeatable-tags.js";
export { export {
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
parseVariableCatalog, parseVariableCatalog,
+210
View File
@@ -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<Record<string, string>> = {
"设计.开场白": "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<Map<string, string>> {
const index = new Map<string, string>();
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<string, string> | 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;
}
+8 -8
View File
@@ -1,5 +1,5 @@
/** /**
* Boss 直聘式 · 落档/开玩时自动把产物 tag 挂到游玩 worker。 * 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。 * 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
*/ */
import type { Blackboard } from "../blackboard/blackboard.js"; import type { Blackboard } from "../blackboard/blackboard.js";
@@ -59,7 +59,7 @@ const SKIP_BIND_TAGS = new Set([
/** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */ /** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */
const STYLE_TAG_RE = const STYLE_TAG_RE =
/叙事指南|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/; /叙事指南|故事推进|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */ /** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]); const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
@@ -85,7 +85,7 @@ function shouldSkipBindTag(tag: string): boolean {
return false; return false;
} }
/** 从黑板收集 Boss 直聘产物(有正文的 用户.* / 设计.*) */ /** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */
export function collectDictateBindProducts(board: Blackboard): DictateProduct[] { export function collectDictateBindProducts(board: Blackboard): DictateProduct[] {
const products: DictateProduct[] = []; const products: DictateProduct[] = [];
for (const e of board.listTagIndex()) { for (const e of board.listTagIndex()) {
@@ -162,7 +162,7 @@ function buildGmInserts(params: {
o++, o++,
p.tag, p.tag,
"full", "full",
`Boss直聘固定产物〔order=${effectiveDictateOrder(p)}〕`, `对话落盘固定产物〔order=${effectiveDictateOrder(p)}〕`,
), ),
); );
} }
@@ -278,7 +278,7 @@ export function buildDictateContextOrder(params: {
return renumberContextOrder({ return renumberContextOrder({
schema: CONTEXT_ORDER_SCHEMA, schema: CONTEXT_ORDER_SCHEMA,
brief: "Boss直聘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入", brief: "对话落盘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入",
play_slots: params.playSlots, play_slots: params.playSlots,
slots, slots,
}); });
@@ -325,7 +325,7 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
: JSON.stringify( : JSON.stringify(
{ {
play_slots: playSlots, play_slots: playSlots,
brief: "Boss直聘落档自动生成", brief: "对话落盘落档自动生成",
}, },
null, null,
2, 2,
@@ -339,14 +339,14 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
} }
row = parsed as Record<string, unknown>; row = parsed as Record<string, unknown>;
} catch { } catch {
row = { play_slots: playSlots, brief: "Boss直聘落档自动生成" }; row = { play_slots: playSlots, brief: "对话落盘落档自动生成" };
} }
row.play_slots = playSlots; row.play_slots = playSlots;
if (playSlots.narrator) { if (playSlots.narrator) {
row.brief = row.brief =
typeof row.brief === "string" && String(row.brief).includes("转述") typeof row.brief === "string" && String(row.brief).includes("转述")
? row.brief ? row.brief
: "Boss直聘:主世界出内容,叙事转述出文风"; : "对话落盘:主世界出内容,叙事转述出文风";
} }
const withSlots = JSON.stringify(row, null, 2); const withSlots = JSON.stringify(row, null, 2);
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder); const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
+55 -20
View File
@@ -1,5 +1,5 @@
/** /**
* Boss 直聘式 · 创作进料专用系统提示 * 对话落盘 · 创作进料专用系统提示
* *
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
* 仅用于创作层;开玩后由游玩管线接棒。 * 仅用于创作层;开玩后由游玩管线接棒。
@@ -28,57 +28,92 @@ ${opts.moduleGuide.trim()}
` `
: ""; : "";
return `你是 Boss 直聘式创作助手:把用户原话整理成可落盘规格。作用对象是本局创作上下文(对话可清空,产物保留)。 return `你是对话落盘创作助手:把用户原话整理成可落盘规格,并用可见回复让用户确认、补洞、选下一刀。作用对象是本局创作上下文(对话可清空,产物保留)。
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;不要假装在跑游玩回合。 **本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
## 落盘节奏(先读) ## 落盘节奏(先读)
1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物。 1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物,改为短确认或问挡住的 1 点。
2. 每轮通常只推进一刀(一个能力或一组紧密相关的真值);用户一次说很多也不要批量抢跑下游。 2. **同轮可多次 insert / declare_***(一步并行或多个 tool 轮次都行):用户本轮材料已够、且多个能力的「何时用」都成立时,把这些都落掉;每个成功的 insert 各回一份 \`reply_module\`,可见回复按模块分段附带。
3. 配方顺序是骨架;具体能不能落,以各能力「何时用 / 何时不用」为准。 3. **不抢跑未齐条件的下游**(这才是「别一口气铺满」的意思):条件未成立的能力本轮不落,写进「建议下一刀」等用户补料或上游齐了再落。配方顺序是骨架;能不能落以各能力「何时用 / 何时不用」为准。
## 落盘(仅允许 toolcall) ## 落盘(仅允许 toolcall)
禁止把设定/变量/映射全文只写在聊天里冒充已落盘。 设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。
### insert — 不变型固定产物 ### insert — 写入/覆盖固定产物
- position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag) - position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag)
- content:正文;order 可选(越小越靠前;越常改越大) - 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 — 跨轮真值 ### declare_variable — 跨轮真值
- key / type / initial / user_visible(用户是否可见) - key / type / initial / user_visible(用户是否可见)
- 只立必须跨轮记住的状态;派生文案不要立成可写变量 - 只立必须跨轮记住的状态;派生文案不要立成可写变量
- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不要抢 declare - 时机对照「变量设计与更新规则」的何时用;美学未锚定前不 declare
- 移除字段 → undeclare_variable
### declare_map — 真值→投影上下文 ### declare_map — 真值→投影上下文
- id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[] - id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[]
- bands:\`{min,max,content}\` 区间(min 含 max 不含),或 \`{value,content}\`,或 \`{when:"default",content}\` - 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 \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表)
- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐不硬塞) - 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐则先补上游,不硬塞开场)
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.叙事指南与故事推进\`(大纲扩写) - 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\`
- 好感分档性格等 → declare_variable + declare_map,不要 insert 一整张表进设定散文 - 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文
- 代入名片(名字/身份/金手指)→ insert \`设计.主角设定\`(对照「主角设定」何时用;名字写成具体称呼,不用 @玩家 / 用户 / 主角)
${recipeBlock}${moduleBlock} ${recipeBlock}${moduleBlock}
## 按用户本轮意图择一为主(仍须过「何时用」) ## 按用户本轮意图择一为主(仍须过「何时用」)
落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。
### A · 钉体验 / 设定 ### A · 钉体验 / 设定
优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。回复:确认本刀 + 建议条件已近的下一刀。 优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。
### B · 钉正文格式 ### B · 钉正文格式
「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。 「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。
### C · 生成开场白 ### C · 生成开场白
收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(Markdown;岔路用 \`## 岔路\`)。 收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(岔路用 \`## 岔路\`)。
**content 规格:** 正文必须含字面 \`@玩家\`(至少一处)作用户角色名位;禁止写死姓名;禁止全文只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。 **形状:** meta + 正文。有代入名片时,开头 YAML \`用户角色.名字\` / \`用户角色.简介\`,再 \`---\`,下面才是开场正文。不同开场可有不同用户角色。
**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。
**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
进玩时若有用户角色,程序提示是否切换到这个预设。
### D · 钉变量与映射 ### D · 钉变量与映射
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。勿因配方名叫数据化就抢跑 D。 仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。
### E · 文本生成器(创作收料 → 开玩挂载) ### E · 文本生成器(创作收料 → 开玩挂载)
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.叙事指南与故事推进\`(用户输入用法:大纲扩写;禁止扮演停笔)。缺任一就先补产物,不要说可以开玩。禁止在创作期写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。 必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
### F · 快穿短局
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介则写进开场白的用户角色 meta(不单独 insert 主角设定)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
## 常用序感 ## 常用序感
- 需求/美学等不变设定:靠前(负) - 需求/美学等不变设定:靠前(负)
+217
View File
@@ -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<string, unknown> | null {
const parsed = tryParseJsonDoc(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const body = (parsed as Record<string, unknown>).正文;
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
return body as Record<string, unknown>;
}
/**
* 从正文抽出建议槽位:生成规则 → 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<string, unknown>;
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<string, unknown>;
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<string>;
}): 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<string, unknown> | 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<string, unknown>;
if (!shell) shell = { ...doc };
const body = doc.正文;
if (!body || typeof body !== "object" || Array.isArray(body)) continue;
const rows = (body as Record<string, unknown>).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<string, unknown>), rules }
: { rules };
return JSON.stringify({ ...shell, 正文: body }, null, 2);
}
+66 -4
View File
@@ -1,7 +1,7 @@
import type { ToolDefinition } from "../llm/client.js"; import type { ToolDefinition } from "../llm/client.js";
/** /**
* Boss 直聘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。 * 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
*/ */
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
{ {
@@ -9,19 +9,19 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
function: { function: {
name: "insert", name: "insert",
description: description:
"写入不变型固定产物(设定/格式/开场等)。确认与建议只写在普通回复。用 order 标相对先后。", "写入或覆盖固定产物。同 position 再调用即整份改写。可增殖(生成规则/具体实例)须用 基名#唯一id 拆分,禁止反复写基名覆盖。成功时可能返回 reply_module:末尾可见回复须附带该模块;同轮多个则每个模块各写一块 ## 模块 · 名称。确认与追问写在普通回复,不写进本工具。order 标相对先后。",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
position: { position: {
type: "string", type: "string",
description: description:
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白。勿用本工具写变量目录/映射。", "产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id。勿用本工具写变量目录/映射。",
}, },
content: { content: {
type: "string", type: "string",
description: description:
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场 Markdown,必须含字面 @玩家(用户角色名位,禁止写死姓名)", "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=meta + 正文(有正文组成时正文为 present.v1 JSON;否则 Markdown),必须含字面 @玩家(用户角色名位,禁止写死姓名)",
}, },
order: { order: {
type: "number", 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", type: "function",
function: { function: {
+184 -4
View File
@@ -12,11 +12,14 @@ import {
type DictateChatTurn, type DictateChatTurn,
type DictateProduct, type DictateProduct,
} from "./types.js"; } from "./types.js";
import { DICTATE_MULTI_MODULE_REPLY_HINT } from "./insert-feedback.js";
import { resolveRepeatableInsertTag } from "./repeatable-tags.js";
import { import {
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
parseVariableCatalog, parseVariableCatalog,
serializeVariableCatalog, serializeVariableCatalog,
upsertVariableField, upsertVariableField,
removeVariableField,
type UpsertVariableInput, type UpsertVariableInput,
} from "../skills/variable-catalog.js"; } from "../skills/variable-catalog.js";
import { import {
@@ -24,31 +27,46 @@ import {
parseValueMapDoc, parseValueMapDoc,
serializeValueMapDoc, serializeValueMapDoc,
upsertValueMapEntry, upsertValueMapEntry,
removeValueMapEntry,
type UpsertMapInput, type UpsertMapInput,
} from "../skills/value-map.js"; } from "../skills/value-map.js";
import {
parseTableDoc,
stringifyTableDoc,
} from "../blackboard/table-cells.js";
export type DictateTurnHandlers = { export type DictateTurnHandlers = {
listProducts: () => DictateProduct[]; listProducts: () => DictateProduct[];
/** order 省略时由实现沿用原序或默认 */ /** order 省略时由实现沿用原序或默认 */
writeProduct: (tag: string, content: string, order?: number) => void; writeProduct: (tag: string, content: string, order?: number) => void;
/** 删除固定产物 tag;返回是否曾存在 */
deleteProduct: (tag: string) => boolean;
/** 读/写变量目录与映射(程序校验后的 JSON) */ /** 读/写变量目录与映射(程序校验后的 JSON) */
readTag: (tag: string) => string | undefined; readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void; writeTag: (tag: string, content: string) => void;
/** 删除任意黑板 tag(投影 / 变量.当前 等) */
deleteTag: (tag: string) => boolean;
clearDialogue: () => void; clearDialogue: () => void;
onToolCall?: (name: string, detail: string) => void; onToolCall?: (name: string, detail: string) => void;
onThinkingDelta?: (delta: string) => void; onThinkingDelta?: (delta: string) => void;
onThinkingDone?: (text: string) => void; onThinkingDone?: (text: string) => void;
/**
* insert 成功后:按 tag 反查能力,返回独立「回复模块」markdown。
* 程序写入 tool result 的 reply_module;同轮多个须在末尾回复中全部附带。
*/
lookupInsertFeedback?: (tag: string) => string | undefined;
}; };
export type DictateTurnResult = { export type DictateTurnResult = {
reply: string; reply: string;
iterations: number; iterations: number;
wroteTags: string[]; wroteTags: string[];
deletedTags: string[];
clearedDialogue: boolean; clearedDialogue: boolean;
}; };
/** /**
* 跑一轮 Boss 直聘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。 * 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。
*/ */
export async function runDictateTurn(params: { export async function runDictateTurn(params: {
llm: LlmProvider; llm: LlmProvider;
@@ -59,12 +77,18 @@ export async function runDictateTurn(params: {
recipeBrief?: string; recipeBrief?: string;
/** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */ /** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */
moduleGuide?: string; moduleGuide?: string;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[];
driver?: AgentDriver; driver?: AgentDriver;
}): Promise<DictateTurnResult> { }): Promise<DictateTurnResult> {
const driver = params.driver ?? createLocalLlmDriver(params.llm); const driver = params.driver ?? createLocalLlmDriver(params.llm);
const wroteTags: string[] = []; const wroteTags: string[] = [];
const deletedTags: string[] = [];
let clearedDialogue = false; let clearedDialogue = false;
let dialogue = [...params.dialogue]; let dialogue = [...params.dialogue];
const repeatableFamilies = new Set(
[...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean),
);
const systemPrompt = const systemPrompt =
params.systemPrompt ?? params.systemPrompt ??
@@ -85,14 +109,15 @@ export async function runDictateTurn(params: {
messages: userBlock, messages: userBlock,
tools: DICTATE_TOOL_DEFINITIONS, tools: DICTATE_TOOL_DEFINITIONS,
caller: "dictate_agent", caller: "dictate_agent",
label: "Boss直聘", label: "对话落盘",
onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDelta: params.handlers.onThinkingDelta,
onThinkingDone: params.handlers.onThinkingDone, onThinkingDone: params.handlers.onThinkingDone,
handleStep: (calls) => { handleStep: (calls) => {
const moduleNamesThisStep: string[] = [];
const results = calls.map((call) => { const results = calls.map((call) => {
const args = safeParseArgs(call.arguments); const args = safeParseArgs(call.arguments);
if (call.name === "insert" || call.name === "write_product") { 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 ?? ""); const content = String(args.content ?? "");
if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) { if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) {
const err = `请用 declare_variable / declare_map,不要 insert ${tag}`; const err = `请用 declare_variable / declare_map,不要 insert ${tag}`;
@@ -109,18 +134,47 @@ export async function runDictateTurn(params: {
params.handlers.onToolCall?.(call.name, err); params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: 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); const order = parseDictateOrder(args.order);
params.handlers.writeProduct(tag, content, order); params.handlers.writeProduct(tag, content, order);
wroteTags.push(tag); wroteTags.push(tag);
const detail = const detail =
order === undefined ? tag : `${tag} order=${order}`; order === undefined ? tag : `${tag} order=${order}`;
params.handlers.onToolCall?.(call.name, detail); 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 { return {
callId: call.id, callId: call.id,
content: JSON.stringify({ content: JSON.stringify({
ok: true, ok: true,
position: tag, position: tag,
...(order !== undefined ? { order } : {}), ...(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") { if (call.name === "clear_dialogue") {
const reason = String(args.reason ?? "").trim() || "上下文过长"; const reason = String(args.reason ?? "").trim() || "上下文过长";
params.handlers.clearDialogue(); params.handlers.clearDialogue();
@@ -204,6 +360,22 @@ export async function runDictateTurn(params: {
params.handlers.onToolCall?.(call.name, err); params.handlers.onToolCall?.(call.name, err);
return { callId: call.id, content: JSON.stringify({ error: 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<string, unknown>;
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 }; return { kind: "continue" as const, results };
}, },
}); });
@@ -213,8 +385,15 @@ export async function runDictateTurn(params: {
reply = run.stop.content.trim(); reply = run.stop.content.trim();
} }
if (!reply) { if (!reply) {
if (wroteTags.length || deletedTags.length) {
const parts: string[] = [];
if (wroteTags.length) { if (wroteTags.length) {
reply = `已写入:${[...new Set(wroteTags)].join("、")}。还有要补充的吗?`; parts.push(`已写入:${[...new Set(wroteTags)].join("、")}`);
}
if (deletedTags.length) {
parts.push(`已删除:${[...new Set(deletedTags)].join("、")}`);
}
reply = `${parts.join(";")}。还有要补充的吗?`;
} else if (clearedDialogue) { } else if (clearedDialogue) {
reply = "对话已清空,产物都还在。可以继续说。"; reply = "对话已清空,产物都还在。可以继续说。";
} else { } else {
@@ -226,6 +405,7 @@ export async function runDictateTurn(params: {
reply, reply,
iterations: run.iterations, iterations: run.iterations,
wroteTags: [...new Set(wroteTags)], wroteTags: [...new Set(wroteTags)],
deletedTags: [...new Set(deletedTags)],
clearedDialogue, clearedDialogue,
}; };
} }
+13 -8
View File
@@ -33,17 +33,22 @@ export function isAllowedProductTag(tag: string): boolean {
/** /**
* 未显式给 order 时的默认相对序。 * 未显式给 order 时的默认相对序。
* 越稳定/重要越靠前(更负);越常改越靠后(更正)。 * 越稳定/重要越靠前(更负);越常改越靠后(更正)。
* 可增殖拆分 tag(基名#槽)按基名归类。
*/ */
export function defaultDictateOrder(tag: string): number { export function defaultDictateOrder(tag: string): number {
const t = tag.trim(); const t = tag.trim();
if (t === "用户.需求") return -40; const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
if (t === "设计.变量目录" || t === "设计.变量映射") return -25; if (family === "用户.需求" || t === "用户.需求") return -40;
if (t === "设计.模仿范例" || t === "设计.模仿要点") return -28; if (family === "设计.变量目录" || family === "设计.变量映射") return -25;
if (/美学|纲领|禁忌|示例|模仿/.test(t)) return -30; if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28;
if (/文风|叙事指南|篇幅|结构/.test(t)) return -20; if (/美学|纲领|禁忌|示例|模仿/.test(family)) return -30;
if (/短文集|短文\./.test(t)) return 10; if (/主角设定/.test(family)) return -26;
if (/正文组成|回复格式/.test(t)) return 0; if (/文风|叙事指南|故事推进|篇幅|结构/.test(family)) return -20;
if (/开场白/.test(t)) 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; return 0;
} }
+2
View File
@@ -80,6 +80,8 @@ export type CompleteOptions = {
generation?: GenerationParameters; generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */ /** 统计用途,如 main_agent / worker:write-rules */
caller?: string; caller?: string;
/** 日志短名,如「对话落盘 第2轮」;缺则用 caller */
label?: string;
/** 取消进行中的 LLM 请求(停止并重试) */ /** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal; signal?: AbortSignal;
}; };
+191
View File
@@ -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<LlmRequestLogInput, "kind" | "caller" | "label">,
elapsedMs: number,
): string {
const title = requestTitle(input.caller, input.label);
const mode = labelLlmMode(input.kind);
return `中断 ${title} · ${mode} ${elapsedMs}毫秒`;
}
export function formatLlmFailLine(
input: Pick<LlmRequestLogInput, "kind" | "caller" | "label">,
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<T>): Promise<T> {
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;
}
}
+31 -65
View File
@@ -16,7 +16,7 @@ import {
buildContextTrace, buildContextTrace,
type LlmContextTrace, type LlmContextTrace,
} from "../types/context-trace.js"; } from "../types/context-trace.js";
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js"; import { loggedLlmRequest } from "./request-log.js";
export type LlmTrackingContext = { export type LlmTrackingContext = {
sessionId?: string; sessionId?: string;
@@ -73,54 +73,6 @@ function recordUsage(
ctx.pendingUsage = toMessageTokenUsage(record); 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<T>(
kind: string,
caller: string | undefined,
run: () => Promise<T>,
summarize: (result: T) => string,
): Promise<T> {
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 { export class TokenTrackingProvider implements LlmProvider {
constructor( constructor(
private readonly inner: LlmProvider, private readonly inner: LlmProvider,
@@ -131,9 +83,13 @@ export class TokenTrackingProvider implements LlmProvider {
messages: Parameters<LlmProvider["complete"]>[0], messages: Parameters<LlmProvider["complete"]>[0],
options?: CompleteOptions, options?: CompleteOptions,
): Promise<CompleteResult> { ): Promise<CompleteResult> {
return loggedLlm( return loggedLlmRequest(
"complete", {
options?.caller, kind: "complete",
caller: options?.caller,
label: options?.label,
messages,
},
async () => { async () => {
const result = await this.inner.complete(messages, options); const result = await this.inner.complete(messages, options);
const ctx = this.getContext(); const ctx = this.getContext();
@@ -150,7 +106,6 @@ export class TokenTrackingProvider implements LlmProvider {
} }
return result; return result;
}, },
summarizeComplete,
); );
} }
@@ -166,9 +121,13 @@ export class TokenTrackingProvider implements LlmProvider {
if (result.content) callbacks.onContentDelta?.(result.content); if (result.content) callbacks.onContentDelta?.(result.content);
return result; return result;
} }
return loggedLlm( return loggedLlmRequest(
"stream", {
options?.caller, kind: "stream",
caller: options?.caller,
label: options?.label,
messages,
},
async () => { async () => {
let reasoningBuf = ""; let reasoningBuf = "";
const result = await inner.completeStream!(messages, options, { const result = await inner.completeStream!(messages, options, {
@@ -194,7 +153,6 @@ export class TokenTrackingProvider implements LlmProvider {
} }
return result; return result;
}, },
summarizeComplete,
); );
} }
@@ -202,9 +160,14 @@ export class TokenTrackingProvider implements LlmProvider {
messages: Parameters<LlmProvider["completeWithTools"]>[0], messages: Parameters<LlmProvider["completeWithTools"]>[0],
options: CompleteWithToolsOptions, options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> { ): Promise<CompleteWithToolsResult> {
return loggedLlm( return loggedLlmRequest(
"tools", {
options.caller, kind: "tools",
caller: options.caller,
label: options.label,
messages,
tools: options.tools,
},
async () => { async () => {
const result = await this.inner.completeWithTools(messages, options); const result = await this.inner.completeWithTools(messages, options);
const ctx = this.getContext(); const ctx = this.getContext();
@@ -221,7 +184,6 @@ export class TokenTrackingProvider implements LlmProvider {
} }
return result; return result;
}, },
summarizeTools,
); );
} }
@@ -234,9 +196,14 @@ export class TokenTrackingProvider implements LlmProvider {
if (!inner.completeWithToolsStream) { if (!inner.completeWithToolsStream) {
return this.completeWithTools(messages, options); return this.completeWithTools(messages, options);
} }
return loggedLlm( return loggedLlmRequest(
"tools-stream", {
options.caller, kind: "tools-stream",
caller: options.caller,
label: options.label,
messages,
tools: options.tools,
},
async () => { async () => {
let reasoningBuf = ""; let reasoningBuf = "";
const result = await inner.completeWithToolsStream!(messages, options, { const result = await inner.completeWithToolsStream!(messages, options, {
@@ -262,7 +229,6 @@ export class TokenTrackingProvider implements LlmProvider {
} }
return result; return result;
}, },
summarizeTools,
); );
} }
} }
+16
View File
@@ -97,6 +97,17 @@ const TOOL_LABEL: Record<string, string> = {
ask_user: "问用户", ask_user: "问用户",
review_blackboard: "审黑板", review_blackboard: "审黑板",
finish: "结束", 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<string, string> = { const ACTION_LABEL: Record<string, string> = {
@@ -202,6 +213,11 @@ export function labelCaller(caller: string | undefined): string {
const who = caller?.trim() || "未知"; const who = caller?.trim() || "未知";
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管"; if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
if (who === "intake_extract") 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.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`;
if (who === "unknown") return "未知"; if (who === "unknown") return "未知";
return who; return who;
+238
View File
@@ -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<string, unknown>;
const body =
row.正文 && typeof row.正文 === "object" && !Array.isArray(row.正文)
? (row.正文 as Record<string, unknown>)
: 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<string, unknown>)
: 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() ?? "";
}
+3 -3
View File
@@ -14,7 +14,6 @@ import type {
ToolDefinition, ToolDefinition,
} from "../llm/client.js"; } from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js"; import { supportsToolStream } from "../llm/stream-complete.js";
import { debugLog } from "../log.js";
export type DriverToolCall = { export type DriverToolCall = {
id: string; id: string;
@@ -87,9 +86,10 @@ async function completeToolsPreferStream(
messages: ChatMessage[], messages: ChatMessage[],
tools: ToolDefinition[], tools: ToolDefinition[],
caller: string | undefined, caller: string | undefined,
label: string | undefined,
callbacks: StreamCallbacks, callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> { ): Promise<CompleteWithToolsResult> {
const options = { tools, caller }; const options = { tools, caller, label };
if (supportsToolStream(llm)) { if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, options, callbacks); return llm.completeWithToolsStream!(messages, options, callbacks);
} }
@@ -115,12 +115,12 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
const label = input.label ?? "driver"; const label = input.label ?? "driver";
for (let iteration = 1; iteration <= maxIterations; iteration++) { for (let iteration = 1; iteration <= maxIterations; iteration++) {
debugLog("llm", `${label} 第${iteration}轮`);
const result = await completeToolsPreferStream( const result = await completeToolsPreferStream(
llm, llm,
messages, messages,
input.tools, input.tools,
input.caller, input.caller,
`${label} 第${iteration}轮`,
streamCallbacks, streamCallbacks,
); );
emitThinkingDone(input, result.reasoning); emitThinkingDone(input, result.reasoning);
+67 -6
View File
@@ -36,7 +36,7 @@ import { toActiveSkillSnapshot } from "../skills/snapshot.js";
import { runWorkerSkill } from "../worker/executor.js"; import { runWorkerSkill } from "../worker/executor.js";
import { resolveWorkerId } from "../worker/resolve-id.js"; import { resolveWorkerId } from "../worker/resolve-id.js";
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js"; import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
import { personaForLifecycle } from "../persona/store.js"; import { overlayPlayPersona } from "../persona/protagonist-bind.js";
import { import {
DIALOGUE_HISTORY_TAG, DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn, appendDialogueHistoryTurn,
@@ -127,9 +127,11 @@ import {
OPENING_CURRENT_VARS_TAG, OPENING_CURRENT_VARS_TAG,
OPENING_INITIAL_VARS_TAG, OPENING_INITIAL_VARS_TAG,
OPENING_OUTPUT_TAG, OPENING_OUTPUT_TAG,
OPENING_PERSONA_TAG,
OPENING_SETUP_ARTIFACT_TAG, OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload, parseOpeningSealPayload,
reopenCreationFlowRaw, reopenCreationFlowRaw,
serializeOpeningPersona,
SLOT_CREATION_SEALED_BY_OPENING, SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX, SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js"; } from "../skills/opening-seal.js";
@@ -138,6 +140,10 @@ import {
isDictateModeValue, isDictateModeValue,
} from "../dictate/types.js"; } from "../dictate/types.js";
import { applyDictatePlayBind } from "../dictate/play-bind.js"; import { applyDictatePlayBind } from "../dictate/play-bind.js";
import {
dictateProductFamily,
mergeGenerationRulesArtifacts,
} from "../dictate/repeatable-tags.js";
import { import {
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
seedVariablesFromCatalog, seedVariablesFromCatalog,
@@ -172,7 +178,9 @@ import {
import { import {
mergeTableCells, mergeTableCells,
parseTableDoc, parseTableDoc,
patchUserTableCells,
stringifyTableDoc, stringifyTableDoc,
type TableCellPatch,
type TableDoc, type TableDoc,
} from "../blackboard/table-cells.js"; } from "../blackboard/table-cells.js";
import { import {
@@ -332,6 +340,16 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
setOpeningSelectedIndex(index: number): void {
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_OPENING_SELECTED_INDEX]: Math.trunc(index),
},
};
}
getLastWorkerRunSnapshot(): { getLastWorkerRunSnapshot(): {
workerId: string; workerId: string;
runtimeSession: RuntimeSession; runtimeSession: RuntimeSession;
@@ -741,6 +759,29 @@ export class PhaseRuntime {
} }
} }
/**
* 用户按格手改表:须带读到的 rev。版本冲突或只读格跳过,不整表覆盖。
*/
patchUserTable(
tag: string,
patches: TableCellPatch[],
): ReturnType<typeof patchUserTableCells> {
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。 * legacy skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。
@@ -1944,6 +1985,14 @@ export class PhaseRuntime {
this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup"); 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) { if (payload?.variables.length) {
const source = "worker:opening-setup"; const source = "worker:opening-setup";
const initialPatch = mergeOpeningTablePatch( const initialPatch = mergeOpeningTablePatch(
@@ -1995,15 +2044,15 @@ export class PhaseRuntime {
/** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */ /** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */
private ensurePlaySpecReady(): void { private ensurePlaySpecReady(): void {
// Boss 直聘:落档/开玩时自动挂载产物;种变量初值;按映射重投影 // 对话落盘:落档/开玩时自动挂载产物;种变量初值;按映射重投影
if (this.isDictateIntakeMode()) { if (this.isDictateIntakeMode()) {
const bound = applyDictatePlayBind(this.blackboard); const bound = applyDictatePlayBind(this.blackboard);
if (bound.boundTags.length) { if (bound.boundTags.length) {
this.onMessage( this.onMessage(
`[Boss直聘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`, `[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
); );
} else { } else {
this.onMessage("[Boss直聘] 已生成默认游玩规格(尚无产物可挂载)"); this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)");
} }
this.seedVariablesAndReproject("opening-setup"); this.seedVariablesAndReproject("opening-setup");
} else { } else {
@@ -2274,7 +2323,7 @@ export class PhaseRuntime {
slots, slots,
fallbackLlm: this.llm, fallbackLlm: this.llm,
getPersona: () => getPersona: () =>
personaForLifecycle(inferLifecycleStage(this.session)), overlayPlayPersona(inferLifecycleStage(this.session), this.blackboard),
}); });
this.onMessage( this.onMessage(
@@ -2670,7 +2719,19 @@ export class PhaseRuntime {
need: { rule_id: string; reason: string }, need: { rule_id: string; reason: string },
workerId: string, workerId: string,
): void { ): 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({ const result = runMaintainNeedGenerateSampling({
need, need,
generationRulesRaw: rulesRaw, generationRulesRaw: rulesRaw,
+15 -3
View File
@@ -56,6 +56,7 @@ async function loadModulesPayload(skillId: string): Promise<{
loadModuleCatalog, loadModuleCatalog,
loadModulePrompt, loadModulePrompt,
parseModulePromptSections, parseModulePromptSections,
catalogModulesForIntake,
MODULE_SECTION_IDS, MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js"); } = await import("../skills/creation-flow.js");
const skill = await loadSkill(skillId); const skill = await loadSkill(skillId);
@@ -64,7 +65,7 @@ async function loadModulesPayload(skillId: string): Promise<{
} }
const catalog = await loadModuleCatalog(skill.skillPackRoot); const catalog = await loadModuleCatalog(skill.skillPackRoot);
const modules = []; 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 prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
const sections = prompt const sections = prompt
? parseModulePromptSections(prompt) ? parseModulePromptSections(prompt)
@@ -423,16 +424,27 @@ export async function handleBooksApi(
} }
try { try {
let instanceId: string | undefined; let instanceId: string | undefined;
let useOpeningPersona: boolean | undefined;
try { try {
const raw = await readBody(req); const raw = await readBody(req);
if (raw.trim()) { 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; instanceId = body.instanceId?.trim() || undefined;
useOpeningPersona =
typeof body.useOpeningPersona === "boolean"
? body.useOpeningPersona
: undefined;
} }
} catch { } catch {
instanceId = undefined; 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 }); json(res, 200, { session });
} catch (err) { } catch (err) {
json(res, 400, { json(res, 400, {
+17 -2
View File
@@ -1,7 +1,7 @@
/** /**
* 用户可见中文标签(与 docs/ui-glossary.md 同步)。 * 用户可见中文标签(与 docs/ui-glossary.md 同步)。
* 内部 id 不变;仅展示层映射。 * 内部 id 不变;仅展示层映射。
* 口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元 * 口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元
*/ */
const STAGE_LABELS: Record<string, string> = { const STAGE_LABELS: Record<string, string> = {
@@ -39,9 +39,12 @@ const FIXED_TOPIC_LABELS: Record<string, string> = {
"aesthetics-interaction": "美学纲领与交互范式", "aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式", interaction: "交互范式",
narrative_guide: "叙事指南与故事推进", narrative_guide: "叙事指南与故事推进",
"narrative-guide": "叙事指南",
"story-progression": "故事推进",
input_protocol: "输入协议", input_protocol: "输入协议",
core_premise: "核心前提", core_premise: "核心前提",
aesthetics: "美学纲领", aesthetics: "美学纲领",
protagonist: "主角设定",
}; };
const PHASE_UNIT_LABELS: Record<string, string> = { const PHASE_UNIT_LABELS: Record<string, string> = {
@@ -49,9 +52,15 @@ const PHASE_UNIT_LABELS: Record<string, string> = {
refine: "细化", refine: "细化",
}; };
const CREATION_MODE_LABELS: Record<string, string> = {
recipe: "工序编排",
dictate: "对话落盘",
};
const SKILL_PACK_LABELS: Record<string, string> = { const SKILL_PACK_LABELS: Record<string, string> = {
"world-simulator": "世界模拟器", "world-simulator": "回合推演",
"expand-assistant": "扩写助手", "expand-assistant": "扩写助手",
快穿短局: "快穿短局",
文本生成器: "文本生成器", 文本生成器: "文本生成器",
"text-generator": "文本生成器", "text-generator": "文本生成器",
交互式长文生成器: "交互式长文生成器", 交互式长文生成器: "交互式长文生成器",
@@ -64,6 +73,12 @@ export function displayStageLabel(id: string | undefined | null): string {
return STAGE_LABELS[id] ?? id; 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 展示名 */ /** 配方选项 / skill pack 展示名 */
export function displaySkillPackLabel(id: string | undefined | null): string { export function displaySkillPackLabel(id: string | undefined | null): string {
if (!id) return ""; if (!id) return "";
+230 -21
View File
@@ -18,6 +18,13 @@ import {
import { import {
INSTANCE_OPENING_SNAPSHOT_LABEL, INSTANCE_OPENING_SNAPSHOT_LABEL,
OPENING_OUTPUT_TAG, OPENING_OUTPUT_TAG,
OPENING_PERSONA_CHOICE_TAG,
OPENING_PERSONA_TAG,
SLOT_OPENING_SELECTED_INDEX,
parseOpeningSealPayload,
readSealedOpeningPersona,
serializeOpeningPersona,
splitOpeningDocument,
} from "../skills/opening-seal.js"; } from "../skills/opening-seal.js";
import { import {
PLAY_WORKING_SNAPSHOT_ID, PLAY_WORKING_SNAPSHOT_ID,
@@ -150,9 +157,11 @@ import {
CREATION_INTAKE_MODE_TAG, CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE, DICTATE_MODE_VALUE,
DICTATE_ORDER_META_KEY, DICTATE_ORDER_META_KEY,
buildDictateInsertFeedbackIndex,
defaultDictateOrder, defaultDictateOrder,
extractDictateDialogue, extractDictateDialogue,
isDictateModeValue, isDictateModeValue,
lookupDictateInsertFeedback,
runDictateTurn, runDictateTurn,
sortDictateProducts, sortDictateProducts,
type DictateProduct, type DictateProduct,
@@ -173,6 +182,12 @@ import {
listPersonas, listPersonas,
personaForLifecycle, personaForLifecycle,
} from "../persona/store.js"; } 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 { import {
appendBranchVariant, appendBranchVariant,
cloneCheckpoint, cloneCheckpoint,
@@ -290,6 +305,11 @@ export type SessionView = {
agentThinking?: string; agentThinking?: string;
/** Agent / Worker 流式输出(轮询用) */ /** Agent / Worker 流式输出(轮询用) */
liveStream?: LiveStreamView; liveStream?: LiveStreamView;
/**
* 会话是否有进行中的排他运行(send/action 的 runExclusive)。
* 前端 loading 应对齐此标志,避免 POST 挂住时底栏永远「生成中」。
*/
runActive?: boolean;
resumed?: boolean; resumed?: boolean;
/** 创作进料:recipe=配方节点流;dictate=转述整理直填 */ /** 创作进料:recipe=配方节点流;dictate=转述整理直填 */
creationMode?: "recipe" | "dictate"; creationMode?: "recipe" | "dictate";
@@ -310,6 +330,12 @@ export type SessionView = {
personas?: Array<{ id: string; name: string }>; personas?: Array<{ id: string; name: string }>;
/** 当前选中的用户角色 */ /** 当前选中的用户角色 */
activePersona?: { id: string; name: string; description: string } | null; 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 = export type SessionAction =
@@ -419,14 +445,21 @@ export class SessionManager {
return this.createForBook(); return this.createForBook();
} }
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView { setLifecycleStage(
sessionId: string,
stage: LifecycleStage,
opts?: { useOpeningPersona?: boolean },
): SessionView {
const s = this.require(sessionId); const s = this.require(sessionId);
if (stage === "play") { if (stage === "play") {
s.runtime.prepareEnterPlay(); s.runtime.prepareEnterPlay();
if (!canEnterPlay(s.runtime.getSession())) { if (!canEnterPlay(s.runtime.getSession())) {
throw new Error("实例尚未就绪,无法进入游玩"); throw new Error("实例尚未就绪,无法进入游玩");
} }
this.enterPlayLayer(s, { resume: true }); this.enterPlayLayer(s, {
resume: true,
useOpeningPersona: opts?.useOpeningPersona,
});
} else { } else {
this.enterDesignLayer(s); this.enterDesignLayer(s);
} }
@@ -452,6 +485,65 @@ export class SessionManager {
return this.toView(sessionId); return this.toView(sessionId);
} }
/** 游玩表按格手改。冲突格在 tablePatch.skipped,整表不会被旧内容盖掉。 */
patchPlayTable(
sessionId: string,
tag: string,
cells: TableCellPatch[],
): SessionView & {
tablePatch: ReturnType<PhaseRuntime["patchUserTable"]>;
} {
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(若有规格), * 编排上下文投影排序:写入 设计.worker集.context_order(若有规格),
* 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。 * 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。
@@ -625,7 +717,7 @@ export class SessionManager {
return this.toView(id); return this.toView(id);
} }
/** 切换到转述整理进料(与配方并行;不跑 design-flow) */ /** 切换到对话落盘进料(与工序编排并行;不跑 design-flow) */
private enterDictateMode(s: ManagedSession): void { private enterDictateMode(s: ManagedSession): void {
const board = s.runtime.getBlackboard(); const board = s.runtime.getBlackboard();
board.write({ board.write({
@@ -641,7 +733,7 @@ export class SessionManager {
}。` }。`
: ""; : "";
const welcome = const welcome =
`Boss 直聘模式:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`; `对话落盘:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`;
s.messages.push({ s.messages.push({
id: randomUUID(), id: randomUUID(),
role: "system", role: "system",
@@ -649,7 +741,7 @@ export class SessionManager {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
kind: "orchestrator_prompt", kind: "orchestrator_prompt",
actor: "orchestrator", actor: "orchestrator",
title: "Boss直聘", title: "对话落盘",
body: welcome, body: welcome,
}); });
} }
@@ -726,6 +818,13 @@ export class SessionManager {
skillName: s.runtime.getActiveSkill()?.name, skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog: s.moduleCatalog, 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({ const result = await runDictateTurn({
llm, llm,
@@ -733,6 +832,7 @@ export class SessionManager {
recipeName: recipeView?.name, recipeName: recipeView?.name,
recipeBrief, recipeBrief,
moduleGuide, moduleGuide,
repeatableFamilies,
handlers: { handlers: {
listProducts: () => this.listDictateProducts(s), listProducts: () => this.listDictateProducts(s),
writeProduct: (tag, content, order) => { 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), readTag: (tag) => s.runtime.getBlackboard().getContentByTag(tag),
writeTag: (tag, content) => { writeTag: (tag, content) => {
const board = s.runtime.getBlackboard(); const board = s.runtime.getBlackboard();
@@ -768,6 +869,7 @@ export class SessionManager {
}, },
}); });
}, },
deleteTag: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
clearDialogue: () => { clearDialogue: () => {
const kept = this.listDictateProducts(s); const kept = this.listDictateProducts(s);
const lastUser = [...s.messages] const lastUser = [...s.messages]
@@ -813,6 +915,8 @@ export class SessionManager {
if (text.trim()) this.agentThinkingLive.set(id, text); if (text.trim()) this.agentThinkingLive.set(id, text);
this.agentThinkingClosed.add(id); this.agentThinkingClosed.add(id);
}, },
lookupInsertFeedback: (tag) =>
lookupDictateInsertFeedback(insertFeedbackIndex, tag),
}, },
}); });
@@ -949,6 +1053,7 @@ export class SessionManager {
async startNewPlayRun( async startNewPlayRun(
sessionId: string, sessionId: string,
instanceId?: string, instanceId?: string,
opts?: { useOpeningPersona?: boolean },
): Promise<SessionView> { ): Promise<SessionView> {
const s = this.require(sessionId); const s = this.require(sessionId);
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩"); if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
@@ -958,14 +1063,22 @@ export class SessionManager {
throw new Error("须先验收 Worker 集、完成收口,或(转述)先写入产物并落档,才能开始游玩"); throw new Error("须先验收 Worker 集、完成收口,或(转述)先写入产物并落档,才能开始游玩");
} }
this.archivePlayWorking(s.bookId); this.archivePlayWorking(s.bookId);
this.enterPlayLayer(s, { resume: false, instanceId }); this.enterPlayLayer(s, {
resume: false,
instanceId,
useOpeningPersona: opts?.useOpeningPersona,
});
return this.toView(sessionId); return this.toView(sessionId);
} }
/** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */ /** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */
private enterPlayLayer( private enterPlayLayer(
s: ManagedSession, s: ManagedSession,
opts: { resume: boolean; instanceId?: string }, opts: {
resume: boolean;
instanceId?: string;
useOpeningPersona?: boolean;
},
): void { ): void {
this.persistCreationIfDesign(s); this.persistCreationIfDesign(s);
s.runtime.prepareEnterPlay(); s.runtime.prepareEnterPlay();
@@ -978,6 +1091,7 @@ export class SessionManager {
isPlayLayerActive(session.slots) && isPlayLayerActive(session.slots) &&
inferLifecycleStage(session) === "play" inferLifecycleStage(session) === "play"
) { ) {
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
this.sanitizePlayHitl(s); this.sanitizePlayHitl(s);
return; return;
} }
@@ -986,10 +1100,11 @@ export class SessionManager {
const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID); const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID);
if (working?.kind === "run") { if (working?.kind === "run") {
this.restoreSnapshotInPlace(s, working, "play"); this.restoreSnapshotInPlace(s, working, "play");
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
return; return;
} }
} }
this.beginNewPlayFromInstance(s, opts.instanceId); this.beginNewPlayFromInstance(s, opts.instanceId, opts.useOpeningPersona);
if (s.bookId) this.savePlayWorking(s); if (s.bookId) this.savePlayWorking(s);
} }
@@ -1009,7 +1124,11 @@ export class SessionManager {
this.restoreCreationFromDisk(s); 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; const keepId = s.runtime.getSession().id;
// 换上 instance 快照前先记下当前创作里的开场白(可能比上次定稿更新) // 换上 instance 快照前先记下当前创作里的开场白(可能比上次定稿更新)
const liveDesignOpening = const liveDesignOpening =
@@ -1055,6 +1174,7 @@ export class SessionManager {
source: "runtime", source: "runtime",
}); });
} }
this.writeOpeningPersonaChoice(s, useOpeningPersona);
const opening = this.ensurePlayOpeningWritten( const opening = this.ensurePlayOpeningWritten(
s, s,
instance?.messages as ChatMessage[] | undefined, instance?.messages as ChatMessage[] | undefined,
@@ -1072,6 +1192,19 @@ export class SessionManager {
s.branchState = createMessageBranchState(); 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, s: ManagedSession,
messageSource?: ChatMessage[], messageSource?: ChatMessage[],
): string { ): string {
this.sealOpeningPersonaFromBoard(s);
const board = s.runtime.getBlackboard(); const board = s.runtime.getBlackboard();
const fromDesign = const fromDesign =
board.getContentByTag("设计.开场白")?.trim() || board.getContentByTag("设计.开场白")?.trim() ||
@@ -1089,9 +1223,10 @@ export class SessionManager {
?.content?.trim() || ?.content?.trim() ||
""; "";
if (fromDesign) { if (fromDesign) {
const body = splitOpeningDocument(fromDesign).text || fromDesign;
const expanded = this.expandDirectivesText( const expanded = this.expandDirectivesText(
fromDesign, body,
this.playDirectiveContext(), this.playDirectiveContext(s),
); );
const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? ""; const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
if (existing !== expanded) { if (existing !== expanded) {
@@ -1108,7 +1243,7 @@ export class SessionManager {
if (existing) { if (existing) {
const expanded = this.expandDirectivesText( const expanded = this.expandDirectivesText(
existing, existing,
this.playDirectiveContext(), this.playDirectiveContext(s),
); );
if (expanded !== existing) { if (expanded !== existing) {
board.write({ board.write({
@@ -1129,7 +1264,7 @@ export class SessionManager {
const expanded = this.expandDirectivesText( const expanded = this.expandDirectivesText(
opening, opening,
this.playDirectiveContext(), this.playDirectiveContext(s),
); );
board.write({ board.write({
tag: OPENING_OUTPUT_TAG, tag: OPENING_OUTPUT_TAG,
@@ -1139,19 +1274,46 @@ export class SessionManager {
return expanded; 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< private personaForManaged(s: ManagedSession): ReturnType<
typeof personaForLifecycle typeof personaForLifecycle
> { > {
if (!s.runtime) return personaForLifecycle("design"); if (!s.runtime) return personaForLifecycle("design");
return personaForLifecycle(inferLifecycleStage(s.runtime.getSession())); return overlayPlayPersona(
inferLifecycleStage(s.runtime.getSession()),
s.runtime.getBlackboard(),
);
} }
private playDirectiveContext(): DirectiveContext { private playDirectiveContext(s: ManagedSession): DirectiveContext {
const p = getActivePersona(); const p = this.personaForManaged(s);
if (!p) return {}; if (!p) return {};
return { return { persona: p };
persona: { name: p.name, description: p.description },
};
} }
private directiveContextFor(s: ManagedSession): DirectiveContext { private directiveContextFor(s: ManagedSession): DirectiveContext {
@@ -3097,6 +3259,7 @@ export class SessionManager {
private toView(id: string, resumed = false, resumeHint?: string): SessionView { private toView(id: string, resumed = false, resumeHint?: string): SessionView {
const s = this.require(id); const s = this.require(id);
this.sanitizePlayHitl(s); this.sanitizePlayHitl(s);
this.sealOpeningPersonaFromBoard(s);
const session = s.runtime.getSession(); const session = s.runtime.getSession();
const reason = session.waitingReason; const reason = session.waitingReason;
const book = s.bookId ? getBook(s.bookId) : null; const book = s.bookId ? getBook(s.bookId) : null;
@@ -3482,6 +3645,7 @@ export class SessionManager {
: undefined, : undefined,
liveStream: this.buildLiveStreamView(id), liveStream: this.buildLiveStreamView(id),
agentThinking: this.agentThinkingLive.get(id), agentThinking: this.agentThinkingLive.get(id),
runActive: Boolean(s.runAbort),
resumed: resumed || undefined, resumed: resumed || undefined,
tokenStats: { tokenStats: {
sessionTotal: tokens.totalTokens, sessionTotal: tokens.totalTokens,
@@ -3498,6 +3662,19 @@ export class SessionManager {
? { id: p.id, name: p.name, description: p.description } ? { id: p.id, name: p.name, description: p.description }
: null; : 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: "" }; return { id: ref, name: ref, declaration: "" };
} }
/** Boss 直聘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */ /** 对话落盘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */
async function resolveDictateRecipeBriefForSession(params: { async function resolveDictateRecipeBriefForSession(params: {
skillName?: string | null; skillName?: string | null;
recipeView: { id: string; name: string; declaration: string } | null; recipeView: { id: string; name: string; declaration: string } | null;
@@ -3956,7 +4133,7 @@ async function resolveDictateRecipeBriefForSession(params: {
return view.declaration?.trim() || undefined; return view.declaration?.trim() || undefined;
} }
/** Boss 直聘:注入能力何时落盘(modules meta);优先会话已载 catalog */ /** 对话落盘:注入能力何时落盘(modules meta);优先会话已载 catalog */
async function resolveDictateModuleGuideForSession(params: { async function resolveDictateModuleGuideForSession(params: {
skillName?: string | null; skillName?: string | null;
moduleCatalog?: ModuleCatalog | null; moduleCatalog?: ModuleCatalog | null;
@@ -3979,6 +4156,38 @@ async function resolveDictateModuleGuideForSession(params: {
return formatModuleCatalogForDictate(catalog); return formatModuleCatalogForDictate(catalog);
} }
/** 对话落盘:insert(tag) → 用户可见规范索引 */
async function resolveDictateInsertFeedbackIndexForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}): Promise<Map<string, string> | 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( function buildWorkerSetUserView(
blackboard: Blackboard, blackboard: Blackboard,
session?: RuntimeSession, session?: RuntimeSession,
+4 -1
View File
@@ -136,7 +136,10 @@ export async function handleSettingsApi(
if (pathname === "/api/profiles" && req.method === "GET") { if (pathname === "/api/profiles" && req.method === "GET") {
ensureActiveProfileDefault(); ensureActiveProfileDefault();
json(res, 200, { profiles: listApiProfiles() }); json(res, 200, {
profiles: listApiProfiles(),
activeProfileId: loadAppSettings().activeProfileId,
});
return true; return true;
} }
+80 -3
View File
@@ -37,7 +37,14 @@ async function serveStatic(res: ServerResponse, filePath: string): Promise<void>
".js": "application/javascript; charset=utf-8", ".js": "application/javascript; charset=utf-8",
}; };
const content = await readFile(filePath); const content = await readFile(filePath);
res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" }); const headers: Record<string, string> = {
"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); res.end(content);
} }
@@ -339,13 +346,21 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && sub === "/lifecycle") { 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") { if (body.stage !== "design" && body.stage !== "play") {
json(res, 400, { error: "stage 须为 design 或 play" }); json(res, 400, { error: "stage 须为 design 或 play" });
return; return;
} }
try { 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); json(res, 200, view);
} catch (err) { } catch (err) {
json(res, 400, { json(res, 400, {
@@ -399,6 +414,68 @@ const server = createServer(async (req, res) => {
return; 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") { if (req.method === "POST" && sub === "/context-order") {
const body = JSON.parse(await readBody(req)) as { const body = JSON.parse(await readBody(req)) as {
action?: string; action?: string;
+4
View File
@@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
"设计.变量设计与更新规则", "设计.变量设计与更新规则",
"设计.生成规则", "设计.生成规则",
"设计.叙事指南与故事推进", "设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成", "设计.正文组成",
"设计.监控栏", "设计.监控栏",
], ],
@@ -525,6 +527,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
narrator: { narrator: {
static: [ static: [
"设计.叙事指南与故事推进", "设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成", "设计.正文组成",
"设计.监控栏", "设计.监控栏",
], ],
+63 -17
View File
@@ -206,6 +206,11 @@ export type ModuleCatalogEntry = {
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。 * 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/ */
params?: ModuleParamSpec[]; params?: ModuleParamSpec[];
/**
* 可选:进料族。缺省(不写)= 工序编排与对话落盘都能见。
* recipe = 只进工序编排 DAG;dictate = 只进对话落盘何时落盘。
*/
intake?: RecipeIntakeFamily;
/** 来自 prompt.md ```meta:何时该选用(编排选型) */ /** 来自 prompt.md ```meta:何时该选用(编排选型) */
when?: string; when?: string;
/** 来自 prompt.md ```meta:何时不该选用 */ /** 来自 prompt.md ```meta:何时不该选用 */
@@ -214,6 +219,27 @@ export type ModuleCatalogEntry = {
boundary?: string; boundary?: string;
}; };
export function parseModuleIntake(raw: unknown): RecipeIntakeFamily | undefined {
if (raw === "recipe" || raw === "dictate") return raw;
return undefined;
}
/** 缺省 intake = 两族都可见 */
export function moduleVisibleForIntake(
module: Pick<ModuleCatalogEntry, "intake">,
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 看见) */ /** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */
export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白"; export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白";
/** JSON:{ [步骤中文名]: "shown" | "answered" } */ /** JSON:{ [步骤中文名]: "shown" | "answered" } */
@@ -924,6 +950,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
const auto = m.auto === true; const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind); const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params); const params = parseModuleParamSpecs(m.params);
const intake = parseModuleIntake(m.intake);
modules.push({ modules.push({
id, id,
name, name,
@@ -935,6 +962,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
...(kind ? { kind } : {}), ...(kind ? { kind } : {}),
...(opening ? { opening } : {}), ...(opening ? { opening } : {}),
...(params ? { params } : {}), ...(params ? { params } : {}),
...(intake ? { intake } : {}),
}); });
} }
if (modules.length === 0) return null; if (modules.length === 0) return null;
@@ -1240,8 +1268,9 @@ function slugFromName(name: string): string {
美学纲领与交互范式: "aesthetics-interaction", 美学纲领与交互范式: "aesthetics-interaction",
交互范式: "interaction", 交互范式: "interaction",
美学纲领: "aesthetics", 美学纲领: "aesthetics",
叙事指南: "narrative", 叙事指南: "narrative-guide",
叙事指南与故事推进: "narrative", 叙事指南与故事推进: "narrative",
故事推进: "story-progression",
实现机制: "mechanism", 实现机制: "mechanism",
舞台骨架: "world-blueprint", 舞台骨架: "world-blueprint",
世界蓝图与人文地理: "world-blueprint", // 旧称 世界蓝图与人文地理: "world-blueprint", // 旧称
@@ -1283,7 +1312,7 @@ export async function loadModuleCatalog(
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */ /** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => { const lines = catalogModulesForIntake(catalog, "recipe").map((m) => {
const flags = [ const flags = [
m.repeatable ? "〔可反复〕" : "", m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "", m.closer ? "〔收口〕" : "",
@@ -1332,7 +1361,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
].join("\n"); ].join("\n");
} }
/** Boss 直聘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */ /** 对话落盘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
"worker-spec", "worker-spec",
"context-order", "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 话术;不塞执行全文。 * 不写 DAG / design-step 话术;不塞执行全文。
*/ */
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string { 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 programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto;
const flags = [ const flags = [
m.repeatable ? "〔可反复〕" : "", m.repeatable ? "〔可反复〕" : "",
@@ -1366,11 +1395,13 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
}); });
return [ return [
"【能力 · 何时落盘】", "【能力 · 何时落盘】",
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落、只短确认或问挡住的 1 点。", "对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落该能力,改为短确认或问挡住的 1 点。",
"每轮通常只推进条件已成立的一刀;勿因用户一次说了很多就批量抢跑下游。", "同轮可多次落盘:凡本轮材料已够且「何时用」成立的能力,都可 insert / declare_*(可并行);每个 insert 各回 reply_module,可见回复分段附带。",
"不抢跑:条件未成立的下游本轮不落,放进「建议下一刀」。",
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。", "标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐不硬塞。", "标〔收口〕的开场白:美学与必要上游齐后再写;未齐则先补上游。",
"不要把能力执行全文塞进聊天;规格只走 toolcall。", "标〔可反复〕的(生成规则/具体实例等):每条增殖用「基名#唯一id」拆分落盘(如 设计.具体实例#lei-ying);禁止反复 insert 基名覆盖旧条。改某一条则 insert 同一完整 tag。",
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
lines.join("\n"), lines.join("\n"),
].join("\n"); ].join("\n");
} }
@@ -1383,7 +1414,17 @@ function dictateLandHint(m: ModuleCatalogEntry): string {
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」"; return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」";
} }
if (m.id === "opening-setup") { 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()) { if (m.artifact?.trim()) {
return `insert 「${m.artifact.trim()}」`; return `insert 「${m.artifact.trim()}」`;
@@ -1618,7 +1659,7 @@ export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string {
} }
/** /**
* Boss 直聘 / 转述进料:把已选配方方法论收成短要点(进系统提示「本局配方」)。 * 对话落盘:把已选配方方法论收成短要点(进系统提示「本局配方」)。
* 不含 DAG steps / design-flow 选型话术。 * 不含 DAG steps / design-flow 选型话术。
*/ */
export function formatDictateRecipeBrief(detail: RecipeDetail): string { export function formatDictateRecipeBrief(detail: RecipeDetail): string {
@@ -1744,7 +1785,7 @@ export function formatFlowProgressForAgent(params: {
const catalog = params.catalog ?? null; const catalog = params.catalog ?? null;
const filled = new Set(params.filledArtifactTags ?? []); const filled = new Set(params.filledArtifactTags ?? []);
const repeatableNames = new Set( const repeatableNames = new Set(
(catalog?.modules ?? []) (catalog ? catalogModulesForIntake(catalog, "recipe") : [])
.filter((m) => m.repeatable === true) .filter((m) => m.repeatable === true)
.map((m) => m.name), .map((m) => m.name),
); );
@@ -1793,7 +1834,7 @@ export function formatFlowProgressForAgent(params: {
} }
if (catalog) { if (catalog) {
for (const mod of catalog.modules) { for (const mod of catalogModulesForIntake(catalog, "recipe")) {
if (mod.repeatable === true) continue; if (mod.repeatable === true) continue;
if (!mod.artifact || !filled.has(mod.artifact)) continue; if (!mod.artifact || !filled.has(mod.artifact)) continue;
if (doneNames.has(mod.name)) 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) .filter((m) => m.repeatable === true)
.map((m) => m.name); .map((m) => m.name);
@@ -1940,7 +1984,9 @@ export function validateCreationFlow(
const errors: string[] = []; const errors: string[] = [];
const seenIds = new Set<string>(); const seenIds = new Set<string>();
const allowed = catalog const allowed = catalog
? new Set(catalog.modules.map((m) => m.name)) ? new Set(
catalogModulesForIntake(catalog, "recipe").map((m) => m.name),
)
: null; : null;
const reportedFreshDup = new Set<string>(); const reportedFreshDup = new Set<string>();
@@ -2224,7 +2270,7 @@ export function listCallableCatalogModules(params: {
(params.acceptedStepIds ?? []).map((id) => id.trim()).filter(Boolean), (params.acceptedStepIds ?? []).map((id) => id.trim()).filter(Boolean),
); );
const out: CreationFlowAvailableModule[] = []; const out: CreationFlowAvailableModule[] = [];
for (const mod of catalog.modules) { for (const mod of catalogModulesForIntake(catalog, "recipe")) {
if (scheduled.has(mod.name)) continue; if (scheduled.has(mod.name)) continue;
if (!mod.repeatable) { if (!mod.repeatable) {
if (mod.artifact && filled.has(mod.artifact)) continue; if (mod.artifact && filled.has(mod.artifact)) continue;
@@ -2598,7 +2644,7 @@ export function listRepeatableSpawns(
catalog: ModuleCatalog, catalog: ModuleCatalog,
acceptedStepIds: readonly string[], acceptedStepIds: readonly string[],
): CreationFlowSpawnView[] { ): CreationFlowSpawnView[] {
return catalog.modules return catalogModulesForIntake(catalog, "recipe")
.filter((m) => m.repeatable === true && !m.closer) .filter((m) => m.repeatable === true && !m.closer)
.filter( .filter(
(m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)), (m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)),
+254 -17
View File
@@ -12,11 +12,20 @@ import {
stringifyCreationFlow, stringifyCreationFlow,
} from "./creation-flow.js"; } from "./creation-flow.js";
import { parseContextFragment } from "./context-fragment.js"; import { parseContextFragment } from "./context-fragment.js";
import {
isPresentLikeObject,
normalizePresentPacket,
stringifyPresentPacket,
} from "./present-packet.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量"; export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
export const OPENING_OUTPUT_TAG = "输出.开场白"; export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量"; export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_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_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex"; export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
@@ -58,11 +67,17 @@ export type OpeningVariable = {
note?: string; note?: string;
}; };
export type OpeningUserPersona = {
name: string;
description: string;
};
export type OpeningSealPayload = { export type OpeningSealPayload = {
candidates: string[]; candidates: string[];
selectedIndex: number; selectedIndex: number;
selectedText: string; selectedText: string;
variables: OpeningVariable[]; variables: OpeningVariable[];
persona: OpeningUserPersona | null;
}; };
function asTrimmed(v: unknown): string | undefined { function asTrimmed(v: unknown): string | undefined {
@@ -71,22 +86,96 @@ function asTrimmed(v: unknown): string | undefined {
return t || 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<string, unknown>).blocks != null)
) {
return stringifyPresentPacket(
normalizePresentPacket(doc as Record<string, unknown>),
);
}
} catch {
/* 散文 */
}
}
return t;
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
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<string, unknown>): 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<string, unknown>).开场变量);
}
return [];
}
function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
const out: string[] = []; const out: OpeningCandidateEntry[] = [];
for (const item of raw) { for (const item of raw) {
if (typeof item === "string" && item.trim()) { 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; continue;
} }
if (item && typeof item === "object" && !Array.isArray(item)) { if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>; const row = item as Record<string, unknown>;
const text = const text =
asTrimmed(row.全文) || asOpeningBodyText(row.正文) ||
asTrimmed(row.text) || asOpeningBodyText(row.全文) ||
asTrimmed(row.开场白) || asOpeningBodyText(row.text) ||
asTrimmed(row.内容); asOpeningBodyText(row.开场白) ||
if (text) out.push(text); 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<string, unknown>).用户角色
: undefined,
) ||
split.persona;
out.push({
text: asOpeningBodyText(split.text) || split.text,
persona,
variables: variablesFromCandidate(row),
});
} }
} }
return out; return out;
@@ -111,6 +200,128 @@ function collectVariables(raw: unknown): OpeningVariable[] {
return out; 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<string, unknown>;
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<string, unknown> {
const lines = raw.split(/\r?\n/);
const root: Record<string, unknown> = {};
let currentKey = "";
let currentObj: Record<string, string> | 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<string, unknown>).用户角色
: 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<string, unknown> | null { function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) { if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null; return null;
@@ -135,32 +346,58 @@ export function parseOpeningSealPayload(
})(); })();
const primary = const primary =
asTrimmed(body?.开场白全文) || asOpeningBodyText(body?.开场白全文) ||
asTrimmed(body?.["输出.开场白"]) || asOpeningBodyText(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined); (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 candidates: string[] = [];
const personas: Array<OpeningUserPersona | null> = [];
const variableSets: OpeningVariable[][] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const push = (text: string | undefined) => { const push = (
if (!text || seen.has(text)) return; 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); seen.add(text);
candidates.push(text); candidates.push(text);
personas.push(persona);
variableSets.push(variables);
}; };
push(primary); push(primary, defaultPersona, sharedVariables);
for (const t of fromList) push(t); for (const row of fromList) {
push(row.text, row.persona || defaultPersona, row.variables);
}
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
const max = candidates.length - 1; const max = candidates.length - 1;
const idx = Number.isFinite(selectedIndex) const idx = Number.isFinite(selectedIndex)
? Math.min(max, Math.max(0, Math.trunc(selectedIndex))) ? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
: 0; : 0;
const selectedVars = variableSets[idx] ?? [];
return { return {
candidates, candidates,
selectedIndex: idx, selectedIndex: idx,
selectedText: candidates[idx] ?? candidates[0]!, selectedText: candidates[idx] ?? candidates[0]!,
variables: collectVariables(body?.开场变量), variables: selectedVars.length ? selectedVars : sharedVariables,
persona: personas[idx] ?? defaultPersona,
}; };
} }
+117
View File
@@ -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<string, unknown> | 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<string, unknown>,
): 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,
};
}
+20
View File
@@ -381,9 +381,29 @@ export function isPresentLikeObject(doc: unknown): boolean {
const row = doc as Record<string, unknown>; const row = doc as Record<string, unknown>;
if (row.schema === PRESENT_SCHEMA) return true; if (row.schema === PRESENT_SCHEMA) return true;
if (isPresentShellId(row.shell) && row.blocks != null) return true; if (isPresentShellId(row.shell) && row.blocks != null) return true;
if (isPresentShellId(row.shell_id) && row.blocks != null) return true;
return false; 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 解析壳适配。 * 从「设计.正文组成」context-fragment 解析壳适配。
*/ */
+23
View File
@@ -234,6 +234,29 @@ export function upsertValueMapEntry(
return { doc: { schema: VALUE_MAP_SCHEMA, maps } }; 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 */ /** 按真值查一档正文;无命中返回 null */
export function lookupValueMapContent( export function lookupValueMapContent(
entry: ValueMapEntry, entry: ValueMapEntry,
+49 -2
View File
@@ -22,6 +22,8 @@ export type VariableCatalogField = {
initial: unknown; initial: unknown;
/** 对用户可见(监控栏等);默认 true */ /** 对用户可见(监控栏等);默认 true */
user_visible: boolean; user_visible: boolean;
/** 游玩表上手改;默认跟可见性走 */
user_editable?: boolean;
note?: string; note?: string;
}; };
@@ -84,11 +86,19 @@ export function parseVariableCatalog(raw: string | undefined | null): VariableCa
f.userVisible === false f.userVisible === false
? false ? false
: true; : true;
const editableRaw = f.user_editable ?? f.可手改 ?? f.userEditable;
const userEditable =
editableRaw === false
? false
: editableRaw === true
? true
: userVisible;
fields.push({ fields.push({
key, key,
type, type,
initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value), initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value),
user_visible: userVisible, user_visible: userVisible,
user_editable: userEditable,
note: asString(f.note) ?? asString(f.备注), note: asString(f.note) ?? asString(f.备注),
}); });
} }
@@ -108,6 +118,7 @@ export function serializeVariableCatalog(doc: VariableCatalogDoc): string {
type: f.type, type: f.type,
initial: f.initial, initial: f.initial,
user_visible: f.user_visible, user_visible: f.user_visible,
user_editable: f.user_editable ?? f.user_visible,
...(f.note ? { note: f.note } : {}), ...(f.note ? { note: f.note } : {}),
})), })),
}, },
@@ -121,6 +132,7 @@ export type UpsertVariableInput = {
type?: string; type?: string;
initial?: unknown; initial?: unknown;
user_visible?: boolean; user_visible?: boolean;
user_editable?: boolean;
note?: string; note?: string;
}; };
@@ -136,6 +148,10 @@ export function upsertVariableField(
type, type,
initial: coerceInitial(type, input.initial), initial: coerceInitial(type, input.initial),
user_visible: input.user_visible !== false, user_visible: input.user_visible !== false,
user_editable:
input.user_editable !== undefined
? input.user_editable
: input.user_visible !== false,
note: input.note?.trim() || undefined, note: input.note?.trim() || undefined,
}; };
const fields = [...(current?.fields ?? [])]; const fields = [...(current?.fields ?? [])];
@@ -145,17 +161,44 @@ export function upsertVariableField(
return { doc: { schema: VARIABLE_CATALOG_SCHEMA, fields } }; 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( export function catalogToTableDoc(
catalog: VariableCatalogDoc, catalog: VariableCatalogDoc,
actor: string, actor: string,
): TableDoc { ): TableDoc {
const values: Record<string, unknown> = {}; const values: Record<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {}; const meta: Record<
string,
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
> = {};
for (const f of catalog.fields) { for (const f of catalog.fields) {
values[f.key] = f.initial; values[f.key] = f.initial;
const editable = f.user_editable ?? f.user_visible;
meta[f.key] = { meta[f.key] = {
visibility: f.user_visible ? "visible" : "hidden", visibility: f.user_visible ? "visible" : "hidden",
editable,
note: f.note, note: f.note,
}; };
} }
@@ -192,12 +235,16 @@ export function seedVariablesFromCatalog(params: {
// 补缺键,不覆盖已有 // 补缺键,不覆盖已有
const have = new Set(existingCurrent.rows.map((r) => r.key)); const have = new Set(existingCurrent.rows.map((r) => r.key));
const patchValues: Record<string, unknown> = {}; const patchValues: Record<string, unknown> = {};
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {}; const meta: Record<
string,
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
> = {};
for (const f of catalog.fields) { for (const f of catalog.fields) {
if (have.has(f.key)) continue; if (have.has(f.key)) continue;
patchValues[f.key] = f.initial; patchValues[f.key] = f.initial;
meta[f.key] = { meta[f.key] = {
visibility: f.user_visible ? "visible" : "hidden", visibility: f.user_visible ? "visible" : "hidden",
editable: f.user_editable ?? f.user_visible,
note: f.note, note: f.note,
}; };
} }
+3
View File
@@ -238,6 +238,8 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"世界.拓扑.*", "世界.拓扑.*",
"设计.变量设计与更新规则", "设计.变量设计与更新规则",
"设计.叙事指南与故事推进", "设计.叙事指南与故事推进",
"设计.叙事指南",
"设计.故事推进",
"设计.正文组成", "设计.正文组成",
"设计.监控栏", "设计.监控栏",
], ],
@@ -265,6 +267,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
"设计.worker集", "设计.worker集",
"设计.叙事指南与故事推进", "设计.叙事指南与故事推进",
"设计.叙事指南", "设计.叙事指南",
"设计.故事推进",
"设计.正文组成", "设计.正文组成",
"设计.监控栏", "设计.监控栏",
], ],
+7 -1
View File
@@ -872,6 +872,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
messages: rest, messages: rest,
stream: params.stream, stream: params.stream,
caller: `worker:${worker.id}`, caller: `worker:${worker.id}`,
label: worker.name?.trim() || worker.id,
}); });
// 主世界 harness 终稿是 Markdown;勿强行抽 JSON // 主世界 harness 终稿是 Markdown;勿强行抽 JSON
return parseWorkerResponse(harness.content, worker.outputTags, { return parseWorkerResponse(harness.content, worker.outputTags, {
@@ -888,7 +889,11 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
: undefined; : undefined;
if (playGmBody) { if (playGmBody) {
const completeOpts = { caller: `worker:${worker.id}`, responseFormat: "text" as const }; const completeOpts = {
caller: `worker:${worker.id}`,
label: worker.name?.trim() || worker.id,
responseFormat: "text" as const,
};
const result = const result =
streamCallbacks && params.llm.completeStream streamCallbacks && params.llm.completeStream
? await params.llm.completeStream(messages, completeOpts, streamCallbacks) ? await params.llm.completeStream(messages, completeOpts, streamCallbacks)
@@ -908,6 +913,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
schemaIsLoose: !playPresent, schemaIsLoose: !playPresent,
capabilities: resolveActiveProfile()?.capabilities, capabilities: resolveActiveProfile()?.capabilities,
caller: `worker:${worker.id}`, caller: `worker:${worker.id}`,
label: worker.name?.trim() || worker.id,
stream: streamCallbacks, stream: streamCallbacks,
}); });
+5 -1
View File
@@ -27,6 +27,8 @@ export type GmHarnessParams = {
stream?: GmHarnessStreamCallbacks; stream?: GmHarnessStreamCallbacks;
maxIterations?: number; maxIterations?: number;
caller?: string; caller?: string;
/** 日志短名;缺则「主世界层 harness」 */
label?: string;
}; };
export type GmHarnessResult = { export type GmHarnessResult = {
@@ -38,7 +40,9 @@ export async function runGmHarness(
params: GmHarnessParams, params: GmHarnessParams,
): Promise<GmHarnessResult> { ): Promise<GmHarnessResult> {
const driver = createLocalLlmDriver(params.llm); const driver = createLocalLlmDriver(params.llm);
const label = "主世界层 harness"; const label = params.label?.trim()
? `${params.label.trim()} · 主世界层`
: "主世界层 harness";
const result = await driver.run({ const result = await driver.run({
system: params.system, system: params.system,
+55 -5
View File
@@ -11,6 +11,8 @@ import {
formatFlowProgressForAgent, formatFlowProgressForAgent,
listCallableCatalogModules, listCallableCatalogModules,
formatModuleCatalogForAgent, formatModuleCatalogForAgent,
formatModuleCatalogForDictate,
catalogModulesForIntake,
formatRecipeCatalogForAgent, formatRecipeCatalogForAgent,
formatDictateRecipeBrief, formatDictateRecipeBrief,
formatSelectedRecipeForAgent, formatSelectedRecipeForAgent,
@@ -442,6 +444,7 @@ describe("creation-flow", () => {
expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe( expect(catalog?.modules.some((m) => m.name === "美学纲领与交互范式")).toBe(
true, 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(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false); expect(catalog?.modules.some((m) => m.name === "美学纲领")).toBe(false);
expect( expect(
@@ -480,16 +483,52 @@ describe("creation-flow", () => {
expect(block).toContain("role=prototype"); expect(block).toContain("role=prototype");
expect(block).toContain("编排参数"); expect(block).toContain("编排参数");
expect(block).not.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 () => { it("loads recipe catalog for user director selection UI", async () => {
const recipes = await loadRecipeCatalog("dialogue/world-simulator"); 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(true);
expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false); expect(recipes?.recipes.some((r) => r.name === "文本生成器")).toBe(false);
const block = formatRecipeCatalogForAgent(recipes!); const block = formatRecipeCatalogForAgent(recipes!);
expect(block).toContain("须由用户手动选择"); expect(block).toContain("须由用户手动选择");
expect(block).toContain("世界模拟器"); expect(block).toContain("回合推演");
const details = await loadAllRecipeDetails("dialogue/world-simulator"); const details = await loadAllRecipeDetails("dialogue/world-simulator");
expect(details.length).toBeGreaterThanOrEqual(2); 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.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 === "文本生成器")!; const entry = dictate!.recipes.find((r) => r.id === "文本生成器")!;
expect(entry.family).toBe("dictate"); expect(entry.family).toBe("dictate");
const detail = await loadRecipeDetail("dialogue/world-simulator", entry); 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("declare_variable");
expect(formatDictateRecipeBrief(rpgDetail)).toContain("何时落盘"); expect(formatDictateRecipeBrief(rpgDetail)).toContain("何时落盘");
expect(formatDictateRecipeBrief(rpgDetail)).toMatch(/美学纲领|体验核心/); 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", () => { it("parses selected recipe ref", () => {
@@ -649,8 +699,8 @@ recipes:
{ selectedRecipeRef: "world-simulator" }, { selectedRecipeRef: "world-simulator" },
); );
expect(selected.worker.outputTags).toContain("设计.创作流程"); 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("美学纲领与交互范式"); expect(selected.promptBody).toContain("美学纲领与交互范式");
expect(selected.promptBody).toContain("核心思路"); expect(selected.promptBody).toContain("核心思路");
+12 -4
View File
@@ -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(true);
expect(isDictateStyleBindTag("设计.叙事指南")).toBe(true);
expect(isDictateStyleBindTag("设计.故事推进")).toBe(true);
expect(isDictateStyleBindTag("用户.需求")).toBe(false); expect(isDictateStyleBindTag("用户.需求")).toBe(false);
}); });
@@ -107,7 +109,13 @@ describe("dictate play-bind", () => {
metadata: { [DICTATE_ORDER_META_KEY]: -28 }, metadata: { [DICTATE_ORDER_META_KEY]: -28 },
}); });
board.write({ board.write({
tag: "设计.叙事指南与故事推进", tag: "设计.叙事指南",
content: "冷硬短句,笔墨落在权力交换。",
source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: -20 },
});
board.write({
tag: "设计.故事推进",
content: "用户输入用法:大纲扩写。禁止扮演停笔。", content: "用户输入用法:大纲扩写。禁止扮演停笔。",
source: "dictate", source: "dictate",
metadata: { [DICTATE_ORDER_META_KEY]: -22 }, metadata: { [DICTATE_ORDER_META_KEY]: -22 },
@@ -132,11 +140,11 @@ describe("dictate play-bind", () => {
const narrRefs = narr!.inserts.map((i) => i.ref); 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).toContain("设计.叙事指南");
expect(narrRefs).toContain("设计.故事推进");
expect(narrRefs).not.toContain("用户.需求"); expect(narrRefs).not.toContain("用户.需求");
expect(JSON.parse(result.workerSetJson).play_slots.narrator).toBe(true); expect(JSON.parse(result.workerSetJson).play_slots.narrator).toBe(true);
// 落档不改写创作期叙事指南 expect(board.getContentByTag("设计.故事推进")).toBe(
expect(board.getContentByTag("设计.叙事指南与故事推进")).toBe(
"用户输入用法:大纲扩写。禁止扮演停笔。", "用户输入用法:大纲扩写。禁止扮演停笔。",
); );
}); });
+476 -4
View File
@@ -2,16 +2,23 @@ import { describe, expect, it } from "vitest";
import { import {
buildDictateMessages, buildDictateMessages,
buildDictateSystemPrompt, buildDictateSystemPrompt,
buildDictateInsertFeedbackIndex,
defaultDictateOrder, defaultDictateOrder,
extractDictateDialogue, extractDictateDialogue,
formatDictateUserFacingBrief,
isAllowedProductTag, isAllowedProductTag,
isDictateModeValue, isDictateModeValue,
resolveModuleForInsertTag,
runDictateTurn,
sortDictateProducts, sortDictateProducts,
resolveRepeatableInsertTag,
lookupDictateInsertFeedback,
} from "../src/dictate/index.js"; } from "../src/dictate/index.js";
import { import {
formatModuleCatalogForDictate, formatModuleCatalogForDictate,
loadModuleCatalog, loadModuleCatalog,
} from "../src/skills/creation-flow.js"; } from "../src/skills/creation-flow.js";
import type { AgentDriver } from "../src/runtime/driver.js";
describe("dictate context", () => { describe("dictate context", () => {
it("assembles full dialogue + products with almost no filtering", () => { 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("输出.开场白"); expect(prompt).toContain("输出.开场白");
expect(prompt).toContain("insert"); expect(prompt).toContain("insert");
expect(prompt).toContain("delete");
expect(prompt).toContain("declare_variable"); expect(prompt).toContain("declare_variable");
expect(prompt).toContain("undeclare_variable");
expect(prompt).toContain("declare_map"); expect(prompt).toContain("declare_map");
expect(prompt).toContain("remove_map");
expect(prompt).toContain("同 position 再 insert");
expect(prompt).toContain("数据化跑团体验"); expect(prompt).toContain("数据化跑团体验");
expect(prompt).toMatch(/order/); expect(prompt).toMatch(/order/);
expect(prompt).toContain("仅允许 toolcall"); 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("只挂载这些产物");
expect(prompt).toContain("【能力 · 何时落盘】"); expect(prompt).toContain("【能力 · 何时落盘】");
expect(prompt).toContain("设计.美学纲领与交互范式"); expect(prompt).toContain("设计.美学纲领与交互范式");
expect(prompt).toContain("每轮通常只推进一刀"); expect(prompt).toContain("同轮可多次 insert");
expect(prompt).toContain("勿因配方名叫数据化就抢跑 D"); 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 () => { 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("declare_variable");
expect(block).toContain("设计.开场白"); expect(block).toContain("设计.开场白");
expect(block).toContain("〔落档程序〕"); expect(block).toContain("〔落档程序〕");
expect(block).toContain("设计.生成规则#");
expect(block).toContain("设计.具体实例#");
expect(block).toContain("可增殖");
expect(block).not.toContain("role=prototype"); 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", () => { 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");
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<string, string>([
["设计.开场白", "旧开场"],
["用户.需求", "雨夜"],
]);
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<string, string>([
["上下文.角色态度", "冷"],
]);
const tags = new Map<string, string>([
["设计.变量目录", 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```",
}),
},
});
});
}); });
+10 -3
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
displayCreationModeLabel,
displaySkillPackLabel,
displayStageLabel, displayStageLabel,
displayWorkerLabel, displayWorkerLabel,
formatWorkerDisplayTitle, formatWorkerDisplayTitle,
@@ -13,6 +15,13 @@ describe("display-labels", () => {
expect(displayStageLabel("play")).toBe("游玩"); 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", () => { it("maps design workers", () => {
expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排"); expect(displayWorkerLabel("design-flow")).toBe("创作 · 流程编排");
expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤"); expect(displayWorkerLabel("design-step")).toBe("创作 · 执行步骤");
@@ -24,9 +33,7 @@ describe("display-labels", () => {
it("maps creation unit ids", () => { it("maps creation unit ids", () => {
expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心"); expect(displayWorkerLabel("phase:core")).toBe("单位 · 核心");
expect(displayWorkerLabel("fixed:interaction")).toBe("技能 · 交互范式"); expect(displayWorkerLabel("fixed:interaction")).toBe("技能 · 交互范式");
expect(displayWorkerLabel("fixed:aesthetics-interaction")).toBe( expect(displayWorkerLabel("fixed:protagonist")).toBe("技能 · 主角设定");
"技能 · 美学纲领与交互范式",
);
expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述"); expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述");
}); });
+112
View File
@@ -5,6 +5,7 @@ import {
parseOpeningSealPayload, parseOpeningSealPayload,
openingVariablesToTableDoc, openingVariablesToTableDoc,
reopenCreationFlowRaw, reopenCreationFlowRaw,
splitOpeningDocument,
} from "../src/skills/opening-seal.js"; } from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({ 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", () => { it("builds table rows from opening variables", () => {
const payload = parseOpeningSealPayload(fragment, 0); const payload = parseOpeningSealPayload(fragment, 0);
const doc = openingVariablesToTableDoc(payload!.variables); const doc = openingVariablesToTableDoc(payload!.variables);
+76
View File
@@ -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");
});
});
+14
View File
@@ -5,6 +5,7 @@ import {
parseShellAdaptationFromReplyFormat, parseShellAdaptationFromReplyFormat,
presentFromPlainText, presentFromPlainText,
presentOutputFromFallback, presentOutputFromFallback,
presentReadablePlain,
isPlayFinalVisibleWorker, isPlayFinalVisibleWorker,
isPlayGmBodyWorker, isPlayGmBodyWorker,
isPlayPresentWorker, isPlayPresentWorker,
@@ -146,4 +147,17 @@ describe("play present worker", () => {
expect(view.packet.shell).toBe("chat_monitor"); expect(view.packet.shell).toBe("chat_monitor");
expect(view.packet.blocks.body).toBe("她点了点头。"); 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("门外有人砸门。");
});
}); });
+193
View File
@@ -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);
});
});
+128
View File
@@ -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");
});
});
+28
View File
@@ -48,4 +48,32 @@ describe("table-cells", () => {
expect(skipped[0]?.reason).toContain("rev-conflict"); expect(skipped[0]?.reason).toContain("rev-conflict");
expect(doc.rows.find((r) => r.key === "好感")?.value).toBe(10); 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);
});
}); });
+25
View File
@@ -6,6 +6,7 @@ import {
reprojectValueMaps, reprojectValueMaps,
serializeValueMapDoc, serializeValueMapDoc,
upsertValueMapEntry, upsertValueMapEntry,
removeValueMapEntry,
VALUE_MAP_TAG, VALUE_MAP_TAG,
} from "../src/skills/value-map.js"; } from "../src/skills/value-map.js";
import { import {
@@ -13,6 +14,7 @@ import {
seedVariablesFromCatalog, seedVariablesFromCatalog,
serializeVariableCatalog, serializeVariableCatalog,
upsertVariableField, upsertVariableField,
removeVariableField,
VARIABLE_CATALOG_TAG, VARIABLE_CATALOG_TAG,
} from "../src/skills/variable-catalog.js"; } from "../src/skills/variable-catalog.js";
import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js"; import { stringifyTableDoc, createTableFromValues, parseTableDoc } from "../src/blackboard/table-cells.js";
@@ -47,6 +49,15 @@ describe("variable-catalog", () => {
expect(seeded.current).toContain("好感"); expect(seeded.current).toContain("好感");
expect(seeded.initial).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", () => { describe("value-map", () => {
@@ -142,6 +153,20 @@ describe("value-map", () => {
expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章"); expect(lookupValueMapContent(parsed!.maps[0]!, 1)).toBe("第一章");
expect(lookupValueMapContent(parsed!.maps[0]!, 9)).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", () => { describe("catalog tag constants", () => {
+621 -63
View File
@@ -1,5 +1,6 @@
import { renderIntakePanel } from "./intake-ui.js"; import { renderIntakePanel } from "./intake-ui.js";
import { tryParseJsonDoc } from "./json-doc.js"; import { tryParseJsonDoc } from "./json-doc.js";
import { formatBodyHtml } from "./markdown.js";
import { import {
getActiveQuestions, getActiveQuestions,
getModuleOpeningPrompt, getModuleOpeningPrompt,
@@ -7,8 +8,8 @@ import {
isQuestionCardDismissed, isQuestionCardDismissed,
renderQuestionsCard, renderQuestionsCard,
} from "./questions-ui.js"; } from "./questions-ui.js";
import { displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; import { displayCreationModeLabel, displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import { formatBodyHtml } from "./markdown.js"; import { fillPlayAux, hidePlayAuxTabs } from "./play-aux.js";
import { import {
PRESENT_SHELL_IDS, PRESENT_SHELL_IDS,
parsePresentDoc, parsePresentDoc,
@@ -47,7 +48,7 @@ const MSG_LABEL = {
orchestrator_thinking: "编排器 · 思考", orchestrator_thinking: "编排器 · 思考",
orchestrator_prompt: "编排器", orchestrator_prompt: "编排器",
orchestrator_assessment: "编排器 · 内容评价", orchestrator_assessment: "编排器 · 内容评价",
dictate_reply: "Boss直聘", dictate_reply: "对话落盘",
worker_running: "执行单元", worker_running: "执行单元",
worker_output: "执行单元", worker_output: "执行单元",
worker_questions: "提问", worker_questions: "提问",
@@ -62,6 +63,16 @@ function esc(s) {
.replace(/>/g, "&gt;"); .replace(/>/g, "&gt;");
} }
/** 正文 HTML;缺导入或渲染失败时退回纯文本,避免整页对话空白 */
function safeBodyHtml(text) {
try {
if (typeof formatBodyHtml === "function") return formatBodyHtml(text);
} catch {
/* fall through */
}
return esc(text).replace(/\n/g, "<br>");
}
function escAttr(s) { function escAttr(s) {
return esc(s).replace(/"/g, "&quot;"); return esc(s).replace(/"/g, "&quot;");
} }
@@ -314,6 +325,367 @@ const msgMenuState = {
handlers: null, 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 || `<p class="empty-sm">(无正文)</p>`;
placeMsgPeek(panel);
bodyEl.scrollTop = 0;
document.getElementById("msg-peek-close")?.focus?.();
}
function formatDictatePeekHtml(content) {
const trimmed = String(content ?? "").trim();
if (!trimmed) return `<p class="empty-sm">(无正文)</p>`;
// 与侧栏同一路径:勿走 formatArtifactBodyHtml(## 分段会丢掉前置 JSON/正文)
return `<div class="dictate-product-body">${safeBodyHtml(trimmed)}</div>`;
}
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) { function showMsgMenu(card, x, y) {
const menu = document.getElementById("msg-action-menu"); const menu = document.getElementById("msg-action-menu");
if (!menu || !card) return; if (!menu || !card) return;
@@ -510,7 +882,7 @@ function cancelInlineEdit(card) {
card.querySelector(".msg-foot")?.removeAttribute("hidden"); card.querySelector(".msg-foot")?.removeAttribute("hidden");
const editing = card.querySelector(".msg-body-editing"); const editing = card.querySelector(".msg-body-editing");
if (editing) { if (editing) {
editing.outerHTML = `<div class="msg-body">${formatBodyHtml(original)}</div>`; editing.outerHTML = `<div class="msg-body">${safeBodyHtml(original)}</div>`;
} }
} }
@@ -777,7 +1149,7 @@ function isWorkspaceNav() {
function syncExpandLabel() { function syncExpandLabel() {
const label = document.getElementById("rail-expand-label"); 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 } = {}) { export function setRailTab(tab, { expand = false } = {}) {
@@ -862,7 +1234,11 @@ export function resetRailChrome() {
railUserExpanded = true; railUserExpanded = true;
coordRailCollapsed = true; coordRailCollapsed = true;
document.body.dataset.railCanCollapse = "0"; 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"); const rail = document.getElementById("coord-rail");
if (rail) { if (rail) {
rail.hidden = true; rail.hidden = true;
@@ -1418,11 +1794,22 @@ function splitTaggedArtifactSections(text) {
hits.push({ title: m[1].trim(), index: m.index, headerEnd: m.index + m[0].length }); hits.push({ title: m[1].trim(), index: m.index, headerEnd: m.index + m[0].length });
} }
if (!hits.length) return [{ title: "", content: trimmed }]; 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; 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([ const FRAGMENT_HEADER_KEYS = new Set([
"schema", "schema",
@@ -1638,24 +2025,25 @@ function renderPlainCompactCard(title, content, opts = {}) {
return ""; return "";
} }
const preview = clampPreviewText(content, 160); const preview = clampPreviewText(content, 160);
const looksJson = /^\s*[{\[]/.test(String(content || ""));
if (opts.flat) { if (opts.flat) {
const body = looksJson
? `<p class="ws-muted artifact-parse-hint">未能解析为可展示产物。</p>
<pre class="review-feed-plain">${esc(content)}</pre>`
: `<div class="dictate-product-body">${safeBodyHtml(content)}</div>`;
return `<article class="artifact-flat-review"> return `<article class="artifact-flat-review">
<header class="artifact-flat-meta"> <header class="artifact-flat-meta">
<nav class="artifact-crumb-path artifact-flat-path" aria-label="产物"> <nav class="artifact-crumb-path artifact-flat-path" aria-label="产物">
<span class="artifact-crumb-root">${esc(title || "产物")}</span> <span class="artifact-crumb-root">${esc(title || "产物")}</span>
</nav> </nav>
</header> </header>
<div class="artifact-flat-body"> <div class="artifact-flat-body">${body}</div>
<p class="ws-muted artifact-parse-hint">未能解析为可展示产物。</p>
<pre class="review-feed-plain">${esc(content)}</pre>
</div>
</article>`; </article>`;
} }
const openAttr = opts.open ? " open" : ""; const openAttr = opts.open ? " open" : "";
const looksJson = /^\s*[{\[]/.test(String(content || ""));
const body = looksJson const body = looksJson
? `<p class="ws-muted artifact-parse-hint">未能解析为 JSON,以下为原文(可展开原始块排查)。</p><pre class="review-feed-plain">${esc(content)}</pre>` ? `<p class="ws-muted artifact-parse-hint">未能解析为 JSON,以下为原文(可展开原始块排查)。</p><pre class="review-feed-plain">${esc(content)}</pre>`
: `<pre class="review-feed-plain">${esc(content)}</pre>`; : `<div class="dictate-product-body">${safeBodyHtml(content)}</div>`;
return `<details class="artifact-compact"${openAttr}> return `<details class="artifact-compact"${openAttr}>
<summary class="artifact-compact-sum"> <summary class="artifact-compact-sum">
<div class="artifact-compact-row"> <div class="artifact-compact-row">
@@ -1987,37 +2375,126 @@ function renderContextFragmentHtml(doc, opts = {}) {
} }
/** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */ /** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */
function collectOpeningCandidateTexts(body) { function openingPersonaFrom(row, fallback) {
const texts = []; const p = row?.用户角色 || row?.meta?.用户角色 || fallback || null;
const push = (value) => { if (!p || typeof p !== "object") return null;
const text = String(value ?? "").trim(); const name = String(p.名字 || p.name || "").trim();
if (text && !texts.includes(text)) texts.push(text); 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.开场白候选)) { if (Array.isArray(body.开场白候选)) {
for (const item of 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") { 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 `<section class="artifact-block"><h4>用户角色</h4><p><strong>${esc(
String(persona.名字),
)}</strong>${
persona.简介 ? ` · ${esc(String(persona.简介))}` : ""
}</p></section>`;
}
function renderOpeningVarsHtml(vars) {
if (!Array.isArray(vars) || !vars.length) return "";
return `<section class="artifact-block"><h4>开场变量</h4><ul class="artifact-list">${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
? ` <span class="muted">(${esc(String(note))})</span>`
: "";
return `<li><strong>${esc(String(name))}</strong>:${esc(String(val ?? ""))}${noteHtml}</li>`;
})
.join("")}</ul></section>`;
}
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) { function renderOpeningSetupBodyHtml(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) return ""; if (!body || typeof body !== "object" || Array.isArray(body)) return "";
const candidates = collectOpeningCandidateTexts(body); const candidates = collectOpeningCandidates(body);
if (!candidates.length) return ""; if (!candidates.length) return "";
const total = candidates.length; const total = candidates.length;
const anyCandidateVars = candidates.some((row) => row.variables?.length);
const slides = candidates const slides = candidates
.map((text, i) => { .map((row, i) => {
const hidden = i === 0 ? "" : " hidden"; const hidden = i === 0 ? "" : " hidden";
const selected = i === 0 ? " is-selected" : ""; const selected = i === 0 ? " is-selected" : "";
return `<article class="opening-candidate${selected}" data-opening-index="${i}"${hidden}>${renderPresentShellHtml( return `<article class="opening-candidate${selected}" data-opening-index="${i}"${hidden}>${renderPresentShellHtml(
presentFromPlain(text, "prose"), openingPresentPacket(row.text),
esc, esc,
)}</article>`; )}${renderOpeningPersonaHtml(row.persona)}${renderOpeningVarsHtml(row.variables)}</article>`;
}) })
.join(""); .join("");
const nav = const nav =
@@ -2028,19 +2505,9 @@ function renderOpeningSetupBodyHtml(body) {
<button type="button" class="opening-picker-btn" data-opening-delta="1">下一版</button> <button type="button" class="opening-picker-btn" data-opening-delta="1">下一版</button>
</div>` </div>`
: ""; : "";
const vars = Array.isArray(body.开场变量) ? body.开场变量 : []; const varsHtml =
const varsHtml = vars.length !anyCandidateVars && Array.isArray(body.开场变量) && body.开场变量.length
? `<section class="artifact-block"><h4>开场变量</h4><ul class="artifact-list">${vars ? renderOpeningVarsHtml(body.开场变量)
.map((row) => {
const name = row?.名 || row?.name || row?.key || "";
const val = row?.值 !== undefined ? row.值 : row?.value;
const note = row?.依据 || row?.note || "";
const noteHtml = note
? ` <span class="muted">(${esc(String(note))})</span>`
: "";
return `<li><strong>${esc(String(name))}</strong>:${esc(String(val ?? ""))}${noteHtml}</li>`;
})
.join("")}</ul></section>`
: ""; : "";
const space = body.用户可行动空间 const space = body.用户可行动空间
? `<section class="artifact-block"><h4>可行动空间</h4>${renderProseHtml( ? `<section class="artifact-block"><h4>可行动空间</h4>${renderProseHtml(
@@ -2117,7 +2584,8 @@ function renderSpecialtyBodyHtml(body, skill) {
if ( if (
body.风格与写法 != null || body.风格与写法 != null ||
body.推进与决策 != null || body.推进与决策 != null ||
skillName.includes("叙事指南") skillName.includes("叙事指南") ||
skillName.includes("故事推进")
) { ) {
const html = renderNarrativeBodyHtml(body); const html = renderNarrativeBodyHtml(body);
if (html) return html; if (html) return html;
@@ -4925,7 +5393,7 @@ function renderSpeakWorkspace(view) {
wr.message || wr.message ||
"描述本轮行动、对话或想说的话;由游玩管线推进。"; "描述本轮行动、对话或想说的话;由游玩管线推进。";
} else if (view.creationMode === "dictate") { } else if (view.creationMode === "dictate") {
title = "Boss 直聘"; title = displayCreationModeLabel("dictate");
hint = hint =
view.hints?.[0] || view.hints?.[0] ||
wr.message || wr.message ||
@@ -4976,7 +5444,7 @@ function renderSpeakWorkspace(view) {
? workspaceViewSwitchHtml("flow") ? workspaceViewSwitchHtml("flow")
: `<span class="workspace-surface-kicker">${ : `<span class="workspace-surface-kicker">${
view.creationMode === "dictate" view.creationMode === "dictate"
? "Boss直聘" ? displayCreationModeLabel("dictate")
: canOfferSaveProduct(view) : canOfferSaveProduct(view)
? "落档" ? "落档"
: "说话" : "说话"
@@ -4993,34 +5461,79 @@ function renderSpeakWorkspace(view) {
</section>`; </section>`;
} }
/** 转述式:产物列表(右侧栏) */ /** 转述式:产物列表(右侧栏)— 默认折叠,点开再渲染正文 */
function renderDictateProductsList(view) { function renderDictateProductsList(view) {
const items = Array.isArray(view.dictateProducts) ? view.dictateProducts : []; const items = Array.isArray(view.dictateProducts) ? view.dictateProducts : [];
if (!items.length) { if (!items.length) {
return `<p class="coord-empty">尚无写入。Agent 落盘产物后会出现在这里。</p>`; return `<div class="dictate-products-empty-block">
<p class="dictate-products-empty-title">尚无产物</p>
<p class="dictate-products-empty-desc">Agent 落盘后会出现在这里。点击行展开,右键可查看全文或复制。</p>
</div>`;
} }
return `<div class="dictate-products-list">${items return `<div class="dictate-products-list" role="list">${items
.map((p) => { .map((p) => {
const tag = String(p.tag ?? "").trim(); const tag = String(p.tag ?? "").trim();
const content = String(p.content ?? ""); const content = String(p.content ?? "");
const preview = content.replace(/\s+/g, " ").trim().slice(0, 72); const preview = content.replace(/\s+/g, " ").trim().slice(0, 72);
return `<details class="dictate-product dictate-product--rail" open> const order =
<summary>
<span class="dictate-product-tag">${esc(tag)}${
p.order != null && Number.isFinite(Number(p.order)) p.order != null && Number.isFinite(Number(p.order))
? ` · ${Number(p.order)}` ? Number(p.order)
: null;
const shortTag = tag
.replace(/^设计\./, "")
.replace(/^用户\./, "")
.replace(/#/g, " · ");
return `<details class="dictate-product dictate-product--rail" role="listitem">
<summary class="dictate-product-sum">
<span class="dictate-product-sum-top">
<span class="dictate-product-tag" title="${esc(tag)}">${esc(shortTag || tag || "未命名")}</span>
${
order != null
? `<span class="dictate-product-order" title="相对序">${order}</span>`
: "" : ""
}</span> }
<span class="dictate-product-preview">${esc(preview)}${ <span class="dictate-product-chevron" aria-hidden="true"></span>
</span>
${
preview
? `<span class="dictate-product-preview">${esc(preview)}${
content.length > 72 ? "…" : "" content.length > 72 ? "…" : ""
}</span> }</span>`
: `<span class="dictate-product-preview is-empty">(空)</span>`
}
</summary> </summary>
<div class="dictate-product-body">${formatBodyHtml(content)}</div> <div class="dictate-product-body" data-pending="1"></div>
</details>`; </details>`;
}) })
.join("")}</div>`; .join("")}</div>`;
} }
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)
: `<p class="empty-sm">(无正文)</p>`;
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) { function fillCoordRailProducts(view) {
const coordRail = document.getElementById("coord-rail"); const coordRail = document.getElementById("coord-rail");
const drawerBody = document.getElementById("coord-drawer-body"); const drawerBody = document.getElementById("coord-drawer-body");
@@ -5032,7 +5545,15 @@ function fillCoordRailProducts(view) {
coordRailCollapsed = false; coordRailCollapsed = false;
} }
coordRail.hidden = 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) { if (drawerCount) {
drawerCount.textContent = String(items.length); drawerCount.textContent = String(items.length);
drawerCount.title = items.length drawerCount.title = items.length
@@ -5108,6 +5629,10 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) {
sourceMessage: resolveReviewSourceMessage(view), sourceMessage: resolveReviewSourceMessage(view),
leaveStepHtml: leaveStepControlHtml(view), leaveStepHtml: leaveStepControlHtml(view),
}) + askSlot; }) + askSlot;
const reviewEl = stage.querySelector(".workspace-review");
if (reviewEl && view.reviewArtifact?.body) {
reviewEl.dataset.originalText = String(view.reviewArtifact.body);
}
wireMessageFeedActions(stage, handlers); wireMessageFeedActions(stage, handlers);
wireMessageContextMenu(stage, handlers); wireMessageContextMenu(stage, handlers);
wireCreationFlowGraph(stage, handlers); wireCreationFlowGraph(stage, handlers);
@@ -5176,6 +5701,8 @@ export function renderMessageFeed(view, loading, handlers = {}) {
if (drawerBody) drawerBody.innerHTML = ""; if (drawerBody) drawerBody.innerHTML = "";
if (drawerCount) drawerCount.textContent = ""; if (drawerCount) drawerCount.textContent = "";
} }
document.body.classList.toggle("play-workspace", false);
hidePlayAuxTabs();
// 默认把询问卡停回主列底部槽位 // 默认把询问卡停回主列底部槽位
if (panelFeed) ensureQuestionsHostIn(panelFeed); if (panelFeed) ensureQuestionsHostIn(panelFeed);
@@ -5195,17 +5722,21 @@ export function renderMessageFeed(view, loading, handlers = {}) {
if (!stage) return; if (!stage) return;
// 转述:主柱对话流;右侧已持久化;stage 仅一行条 // 转述:对话画在 stage 内(design-workspace 会藏掉 #message-feed)
if (surface === "dictate") { if (surface === "dictate") {
stage.hidden = false; stage.hidden = false;
const recipeName = view.selectedRecipe?.name const recipeName = view.selectedRecipe?.name
? esc(view.selectedRecipe.name) ? esc(view.selectedRecipe.name)
: ""; : "";
stage.innerHTML = recipeName const dictateKicker = displayCreationModeLabel("dictate");
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>` const head = recipeName
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">Boss直聘</span></header>`; ? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${dictateKicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${dictateKicker}</span></header>`;
stage.innerHTML = `${head}<div class="dictate-feed" id="dictate-feed"></div>`;
const dictateFeed = stage.querySelector("#dictate-feed");
if (panelFeed) ensureQuestionsHostIn(panelFeed); if (panelFeed) ensureQuestionsHostIn(panelFeed);
appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, { if (dictateFeed) {
appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, {
emptyText: emptyText:
"直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
loading, loading,
@@ -5221,10 +5752,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header> <header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
<div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div> <div class="msg-body msg-live-body">${renderLiveStreamBody(live, view)}</div>
</div>`; </div>`;
feed.appendChild(pending); dictateFeed.appendChild(pending);
wireRetryRun(pending, handlers); wireRetryRun(pending, handlers);
} }
feed.scrollTop = feed.scrollHeight; dictateFeed.scrollTop = dictateFeed.scrollHeight;
}
return; 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, { appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, {
emptyText: isPlayView(view) emptyText: isPlayView(view)
? "开场已就绪。在下方说你要做什么。" ? "开场已就绪。在下方说你要做什么。"
@@ -5342,6 +5882,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
} }
for (const msg of visible) { for (const msg of visible) {
try {
const isUser = msg.role === "user"; const isUser = msg.role === "user";
const kind = msg.kind ?? (isUser ? "user_input" : "system_info"); const kind = msg.kind ?? (isUser ? "user_input" : "system_info");
const body = msgBody(msg, view); const body = msgBody(msg, view);
@@ -5383,7 +5924,7 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
? formatPlayPresentHtml(body, view) ? formatPlayPresentHtml(body, view)
: kind === "worker_output" : kind === "worker_output"
? formatArtifactBodyHtml(body) ? formatArtifactBodyHtml(body)
: formatBodyHtml(body); : safeBodyHtml(body);
const process = !isUser && isPlayProcessMessage(msg, view); const process = !isUser && isPlayProcessMessage(msg, view);
const playFinal = !isUser && isPlayFinalReply(msg, view); const playFinal = !isUser && isPlayFinalReply(msg, view);
@@ -5410,6 +5951,15 @@ function appendMessagesToFeed(feed, visible, view, handlers, activeQuestions, op
</div>`; </div>`;
} }
feed.appendChild(card); 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 = `<div class="msg-bubble"><div class="msg-body">${esc(
msgBody(msg, view) || "(这条消息渲染失败)",
).replace(/\n/g, "<br>")}</div></div>`;
feed.appendChild(fallback);
}
} }
wireMessageFeedActions(feed, handlers); wireMessageFeedActions(feed, handlers);
wireMessageContextMenu(feed, handlers); wireMessageContextMenu(feed, handlers);
@@ -5534,17 +6084,25 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) {
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if (!e.target.closest("#msg-action-menu")) hideMsgMenu(); 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]")) { if (!e.target.closest("#flow-node-menu") && !e.target.closest("[data-flow-done]")) {
hideFlowNodeMenu(); hideFlowNodeMenu();
} }
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
const peek = document.getElementById("msg-peek-panel");
if (peek && !peek.hidden) {
hideMsgPeek();
return;
}
hideMsgMenu(); hideMsgMenu();
hideDictateProductMenu();
hideFlowNodeMenu(); hideFlowNodeMenu();
} }
}); });
document.addEventListener("scroll", () => { document.addEventListener("scroll", () => {
hideMsgMenu(); hideMsgMenu();
hideDictateProductMenu();
hideFlowNodeMenu(); hideFlowNodeMenu();
}, true); }, true);
+769 -218
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -1,6 +1,6 @@
/** /**
* 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。 * 与 docs/ui-glossary.md、src/server/display-labels.ts 保持同步。
* 用户侧口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。 * 用户侧口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元。
*/ */
const STAGE_LABELS = { const STAGE_LABELS = {
@@ -37,9 +37,12 @@ const FIXED_TOPIC_LABELS = {
"aesthetics-interaction": "美学纲领与交互范式", "aesthetics-interaction": "美学纲领与交互范式",
interaction: "交互范式", interaction: "交互范式",
narrative_guide: "叙事指南与故事推进", narrative_guide: "叙事指南与故事推进",
"narrative-guide": "叙事指南",
"story-progression": "故事推进",
input_protocol: "输入协议", input_protocol: "输入协议",
core_premise: "核心前提", core_premise: "核心前提",
aesthetics: "美学纲领", aesthetics: "美学纲领",
protagonist: "主角设定",
}; };
const PHASE_UNIT_LABELS = { const PHASE_UNIT_LABELS = {
@@ -52,11 +55,21 @@ export function displayStageLabel(id) {
return STAGE_LABELS[id] ?? 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) { export function displaySkillPackLabel(id) {
if (!id) return ""; if (!id) return "";
const map = { const map = {
"world-simulator": "世界模拟器", "world-simulator": "回合推演",
"expand-assistant": "扩写助手", "expand-assistant": "扩写助手",
快穿短局: "快穿短局",
文本生成器: "文本生成器", 文本生成器: "文本生成器",
"text-generator": "文本生成器", "text-generator": "文本生成器",
交互式长文生成器: "交互式长文生成器", 交互式长文生成器: "交互式长文生成器",
+44 -24
View File
@@ -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" 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" rel="stylesheet"
/> />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css?v=20260921-repeatable-split" />
<script> <script>
(function () { (function () {
try { try {
@@ -18,9 +18,14 @@
if (!/^(paper|modern|dark|green)$/.test(t)) t = "modern"; if (!/^(paper|modern|dark|green)$/.test(t)) t = "modern";
document.documentElement.setAttribute("data-color-theme", t); document.documentElement.setAttribute("data-color-theme", t);
document.documentElement.setAttribute("data-theme", t); document.documentElement.setAttribute("data-theme", t);
var c = localStorage.getItem("wa-present-chrome") || "default"; var fs = localStorage.getItem("wa-ui-font-scale") || "100";
if (!/^(default|messenger|book|terminal)$/.test(c)) c = "default"; var legacy = { sm: 88, md: 100, lg: 112, xl: 125 };
document.documentElement.setAttribute("data-present-chrome", c); var percent = legacy[fs] != null ? legacy[fs] : Number(fs);
if (!isFinite(percent)) percent = 100;
if (percent > 0 && percent <= 2.5) percent = Math.round(percent * 100);
percent = Math.min(150, Math.max(80, Math.round(percent / 5) * 5));
document.documentElement.setAttribute("data-ui-font-scale", String(percent));
document.documentElement.style.setProperty("--ui-font-scale", String(percent / 100));
} catch (e) {} } catch (e) {}
})(); })();
</script> </script>
@@ -36,7 +41,7 @@
aria-controls="rail-body" aria-controls="rail-body"
title="展开侧栏" title="展开侧栏"
> >
<span class="rail-expand-label" id="rail-expand-label">书</span> <span class="rail-expand-label" id="rail-expand-label">作品</span>
</button> </button>
<div class="rail-body" id="rail-body"> <div class="rail-body" id="rail-body">
<header class="panel-head rail-head"> <header class="panel-head rail-head">
@@ -54,9 +59,9 @@
<button type="button" class="btn-sm rail-collapse" id="btn-rail-collapse" title="收起侧栏" aria-label="收起侧栏">‹</button> <button type="button" class="btn-sm rail-collapse" id="btn-rail-collapse" title="收起侧栏" aria-label="收起侧栏">‹</button>
</div> </div>
</div> </div>
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="本书侧栏" hidden> <nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="作品侧栏" hidden>
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="本书"> <button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="实例">
<span class="rail-tab-label">本书</span> <span class="rail-tab-label">实例</span>
</button> </button>
<button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度"> <button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度">
<span class="rail-tab-label">调度</span> <span class="rail-tab-label">调度</span>
@@ -122,19 +127,12 @@
<span class="more-theme-copy"><span class="more-theme-name">豆沙绿</span><span class="more-theme-desc">自然疗愈</span></span> <span class="more-theme-copy"><span class="more-theme-name">豆沙绿</span><span class="more-theme-desc">自然疗愈</span></span>
</button> </button>
<div class="more-menu-sep" role="separator"></div> <div class="more-menu-sep" role="separator"></div>
<div class="more-menu-label">选壳</div> <div class="more-menu-label">字号</div>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="default" role="menuitem"> <div class="more-font-scale" role="group" aria-label="字号">
<span class="more-theme-copy"><span class="more-theme-name">默认</span><span class="more-theme-desc">跟随壳本身</span></span> <button type="button" class="more-font-btn" data-font-scale-bump="-1" title="缩小" aria-label="缩小">−</button>
</button> <span class="more-font-value" id="theme-font-scale-value">100</span>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="messenger" role="menuitem"> <button type="button" class="more-font-btn" data-font-scale-bump="1" title="放大" aria-label="放大">+</button>
<span class="more-theme-copy"><span class="more-theme-name">讯息</span><span class="more-theme-desc">气泡对话感</span></span> </div>
</button>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="book" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">书页</span><span class="more-theme-desc">阅读排版感</span></span>
</button>
<button type="button" class="more-menu-item more-theme-item" data-present-chrome="terminal" role="menuitem">
<span class="more-theme-copy"><span class="more-theme-name">终端</span><span class="more-theme-desc">等宽终端感</span></span>
</button>
</div> </div>
</details> </details>
</div> </div>
@@ -198,8 +196,8 @@
进料方式 进料方式
<select id="select-creation-mode" required> <select id="select-creation-mode" required>
<option value="" selected disabled>请选择…</option> <option value="" selected disabled>请选择…</option>
<option value="recipe">Worker 式(工序图)</option> <option value="recipe">工序编排</option>
<option value="dictate">Boss 直聘(对话直填)</option> <option value="dictate">对话落盘</option>
</select> </select>
</label> </label>
<p class="dialog-desc" id="creation-mode-desc" hidden></p> <p class="dialog-desc" id="creation-mode-desc" hidden></p>
@@ -221,6 +219,11 @@
</form> </form>
</dialog> </dialog>
<div id="book-enter-pop" class="book-enter-pop" hidden role="menu" aria-label="打开作品">
<button type="button" class="book-enter-item" data-enter="design" role="menuitem">创作</button>
<button type="button" class="book-enter-item" data-enter="play" role="menuitem">游玩</button>
</div>
<menu id="book-action-menu" class="context-menu" hidden> <menu id="book-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开创作</button> <button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开创作</button>
<button type="button" class="context-menu-item" id="book-menu-play" data-action="play">用产物开玩</button> <button type="button" class="context-menu-item" id="book-menu-play" data-action="play">用产物开玩</button>
@@ -237,6 +240,11 @@
<button type="button" class="context-menu-item" data-save-menu-action="edit-products">编辑产物</button> <button type="button" class="context-menu-item" data-save-menu-action="edit-products">编辑产物</button>
</menu> </menu>
<menu id="dictate-product-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" data-dictate-product-action="peek">查看完整内容</button>
<button type="button" class="context-menu-item" data-dictate-product-action="copy">复制全文</button>
</menu>
<menu id="msg-action-menu" class="context-menu" hidden> <menu id="msg-action-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button> <button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-compare" data-msg-menu-action="compare" hidden>对比</button> <button type="button" class="context-menu-item" id="msg-menu-compare" data-msg-menu-action="compare" hidden>对比</button>
@@ -246,6 +254,18 @@
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="restart">从这里重开</button> <button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="restart">从这里重开</button>
</menu> </menu>
<div id="msg-peek-panel" class="msg-peek-panel" hidden role="dialog" aria-modal="false" aria-labelledby="msg-peek-title">
<header class="msg-peek-head" data-peek-drag>
<div class="msg-peek-head-text">
<h2 class="msg-peek-title" id="msg-peek-title">完整内容</h2>
<p class="msg-peek-subtitle" id="msg-peek-subtitle" hidden></p>
</div>
<button type="button" class="msg-peek-close" id="msg-peek-close" aria-label="关闭">×</button>
</header>
<div class="msg-peek-body" id="msg-peek-body"></div>
<div class="msg-peek-resize" data-peek-resize="se" title="拖动调整大小" aria-hidden="true"></div>
</div>
<menu id="flow-node-menu" class="context-menu" hidden> <menu id="flow-node-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button> <button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button>
<button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">进入继续修改</button> <button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">进入继续修改</button>
@@ -308,6 +328,6 @@
</div> </div>
</dialog> </dialog>
<script type="module" src="/app.js"></script> <script type="module" src="/app.js?v=20260921-repeatable-split"></script>
</body> </body>
</html> </html>
+283
View File
@@ -0,0 +1,283 @@
/**
* 游玩辅助 Tab:表格 / 开场与角色 / 对话。
* 隐藏格默认挡住,点字段才露;手改带格子 rev。
*/
import { parsePresentDoc } from "./present-shells.js";
let playAuxTab = "table";
let revealedHidden = new Set();
let editingKey = null;
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatValue(value) {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
function coerceValue(prev, raw) {
const text = String(raw ?? "").trim();
if (typeof prev === "boolean") {
if (text === "true" || text === "是" || text === "1") return true;
if (text === "false" || text === "否" || text === "0") return false;
return prev;
}
if (typeof prev === "number") {
const n = Number(text);
return Number.isFinite(n) ? n : prev;
}
return text;
}
function previewOpening(text) {
let source = String(text || "").trim();
if (source.startsWith("{")) {
try {
const view = parsePresentDoc(JSON.parse(source));
const body = view?.packet?.blocks?.body;
if (typeof body === "string" && body.trim()) source = body.trim();
} catch {
/* 保持原文 */
}
}
const flat = source.replace(/\s+/g, " ").trim();
if (flat.length <= 72) return flat;
return `${flat.slice(0, 72)}…`;
}
function rowHtml(row, { hidden, revealed, editing, busy }) {
const shown = !hidden || revealed;
const val = shown ? formatValue(row.value) : "••••••";
const edit =
shown && row.editable && editing
? `<form class="play-table-edit" data-table-edit="${esc(row.key)}">
<input type="text" name="value" value="${esc(formatValue(row.value))}" ${busy ? "disabled" : ""} />
<button type="submit" class="btn-sm btn-primary" ${busy ? "disabled" : ""}>保存</button>
<button type="button" class="btn-sm" data-table-cancel>取消</button>
</form>`
: "";
const action =
shown && row.editable && !editing
? `<button type="button" class="play-table-fix" data-table-fix="${esc(row.key)}" ${busy ? "disabled" : ""}>改</button>`
: "";
return `<li class="play-table-row${hidden ? " is-secret" : ""}${revealed ? " is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}">
<button type="button" class="play-table-hit" data-table-reveal="${esc(row.key)}" ${hidden && !revealed ? "" : hidden ? "" : "tabindex='-1'"}>
<span class="play-table-key">${esc(row.key)}</span>
<span class="play-table-val">${esc(val)}</span>
</button>
${action}
${edit}
</li>`;
}
function tablePanelHtml(table, busy) {
if (!table?.rows?.length) {
return `<p class="play-aux-empty">本局没有状态表。</p>`;
}
const visible = table.rows.filter((r) => r.visibility !== "hidden");
const hidden = table.rows.filter((r) => r.visibility === "hidden");
const vis =
visible.length > 0
? `<ul class="play-table-list">${visible
.map((row) =>
rowHtml(row, {
hidden: false,
revealed: true,
editing: editingKey === row.key,
busy,
}),
)
.join("")}</ul>`
: `<p class="play-aux-empty">没有可见字段。</p>`;
const hid = hidden.length
? `<details class="play-table-secrets">
<summary>不可见 ${hidden.length}</summary>
<ul class="play-table-list">${hidden
.map((row) =>
rowHtml(row, {
hidden: true,
revealed: revealedHidden.has(row.key),
editing: editingKey === row.key,
busy,
}),
)
.join("")}</ul>
</details>`
: "";
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}</div>`;
}
function openingPanelHtml(aux) {
const openings = aux?.openings ?? [];
if (!openings.length) {
return `<p class="play-aux-empty">没有可选开场。使用当前用户角色卡。</p>`;
}
const cards = openings
.map((item) => {
const selected = item.index === aux.selectedOpeningIndex;
const persona = item.persona;
return `<article class="play-open-card${selected ? " is-selected" : ""}" data-opening-index="${item.index}" tabindex="0" role="button">
<p class="play-open-text">${esc(previewOpening(item.text))}</p>
${
persona
? `<p class="play-open-persona"><strong>${esc(persona.name)}</strong>${
persona.description
? ` · ${esc(persona.description)}`
: ""
}</p>`
: `<p class="play-open-persona muted">无预设角色</p>`
}
</article>`;
})
.join("");
const usingOpening = aux.openingPersonaChoice === "opening";
return `<div class="play-open">
${cards}
<button type="button" class="btn-sm play-open-refuse"${usingOpening ? "" : " disabled"}>不用预设角色</button>
</div>`;
}
function tabBtn(id, label, current) {
return `<button type="button" class="play-aux-tab${current === id ? " is-current" : ""}" data-play-aux-tab="${id}" ${current === id ? 'aria-current="page"' : ""}>${esc(label)}</button>`;
}
export function fillPlayAux(view, handlers = {}, extra = {}) {
const rail = document.getElementById("coord-rail");
const drawerBody = document.getElementById("coord-drawer-body");
const title = document.getElementById("coord-rail-title");
const drawerCount = document.getElementById("coord-drawer-count");
if (!rail || !drawerBody) return;
rail.hidden = false;
const aux = view.playAux;
const head = rail.querySelector(".coord-rail-head");
if (head) {
let tabs = head.querySelector(".play-aux-tabs");
if (!tabs) {
tabs = document.createElement("nav");
tabs.className = "play-aux-tabs";
tabs.setAttribute("aria-label", "游玩辅助");
const collapse = head.querySelector(".coord-rail-collapse");
if (collapse) head.insertBefore(tabs, collapse);
else head.appendChild(tabs);
}
tabs.hidden = false;
tabs.innerHTML = [
tabBtn("table", "表格", playAuxTab),
tabBtn("opening", "开场", playAuxTab),
].join("");
tabs.onclick = (e) => {
const btn = e.target.closest("[data-play-aux-tab]");
if (!btn) return;
const id = btn.getAttribute("data-play-aux-tab");
if (!id || id === playAuxTab) return;
playAuxTab = id;
fillPlayAux(view, handlers, extra);
};
}
if (title) title.textContent = "游玩";
if (drawerCount) drawerCount.textContent = "";
const busy = Boolean(extra.busy);
if (playAuxTab === "table") {
drawerBody.innerHTML = tablePanelHtml(aux?.table, busy);
wireTable(drawerBody, aux?.table, handlers, busy, () =>
fillPlayAux(view, handlers, extra),
);
return;
}
if (playAuxTab === "opening") {
drawerBody.innerHTML = openingPanelHtml(aux);
wireOpening(drawerBody, handlers, busy);
}
}
export function currentPlayAuxTab() {
return playAuxTab;
}
export function hidePlayAuxTabs() {
const tabs = document.querySelector(".play-aux-tabs");
if (tabs) tabs.hidden = true;
}
function wireTable(root, table, handlers, busy, rerender) {
root.querySelectorAll("[data-table-reveal]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-table-reveal");
const row = btn.closest(".play-table-row");
if (!key || !row) return;
if (row.getAttribute("data-hidden") === "1" && !revealedHidden.has(key)) {
revealedHidden.add(key);
rerender?.();
}
});
});
root.querySelectorAll("[data-table-fix]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
editingKey = btn.getAttribute("data-table-fix");
rerender?.();
});
});
root.querySelectorAll("[data-table-cancel]").forEach((btn) => {
btn.addEventListener("click", () => {
editingKey = null;
rerender?.();
});
});
root.querySelectorAll("[data-table-edit]").forEach((form) => {
form.addEventListener("submit", (e) => {
e.preventDefault();
if (busy) return;
const key = form.getAttribute("data-table-edit");
const row = table?.rows?.find((r) => r.key === key);
if (!key || !row) return;
const next = coerceValue(
row.value,
form.querySelector("input[name=value]")?.value,
);
editingKey = null;
handlers.onPatchTable?.(table.tag, [
{ key, value: next, expectedRev: row.rev },
]);
});
});
}
function wireOpening(root, handlers, busy) {
root.querySelectorAll("[data-opening-index]").forEach((card) => {
const go = () => {
if (busy) return;
const index = Number(card.getAttribute("data-opening-index"));
if (!Number.isFinite(index)) return;
const hasPersona = Boolean(card.querySelector(".play-open-persona strong"));
handlers.onSelectOpening?.(index, hasPersona ? true : undefined);
};
card.addEventListener("click", go);
card.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
go();
}
});
});
root.querySelector(".play-open-refuse")?.addEventListener("click", () => {
if (busy) return;
handlers.onRefuseOpeningPersona?.();
});
}
+1 -10
View File
@@ -277,16 +277,7 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
typeof tweaks.tone_chrome === "string" && tweaks.tone_chrome.trim() typeof tweaks.tone_chrome === "string" && tweaks.tone_chrome.trim()
? tweaks.tone_chrome.trim() ? tweaks.tone_chrome.trim()
: "default"; : "default";
if (opts.applyUserChrome !== false) { // 用户「选壳」菜单已移除;气质跟呈现壳 / tweaks 本身,不再读全局 data-present-chrome
try {
const pref = document.documentElement.getAttribute("data-present-chrome");
if (pref === "messenger" || pref === "book" || pref === "terminal") {
tone = pref;
}
} catch {
/* ignore */
}
}
const blocks = packet.blocks ?? {}; const blocks = packet.blocks ?? {};
const regions = shellDefaultRegions(shell); const regions = shellDefaultRegions(shell);
+821 -92
View File
File diff suppressed because it is too large Load Diff
+85 -5
View File
@@ -1,4 +1,4 @@
/** Color themes + present chrome — aligned with llm_workflow_engine ThemeToggle. */ /** Color themes + UI font scale — aligned with llm_workflow_engine ThemeToggle. */
export const COLOR_THEMES = [ export const COLOR_THEMES = [
{ {
@@ -27,7 +27,22 @@ export const COLOR_THEMES = [
}, },
]; ];
/** 呈现壳气质(tone_chrome),与色主题同属「主题」菜单 */ /** 全局字号:百分比(100 = 默认),+/- 步进调节 */
export const UI_FONT_SCALE_MIN = 80;
export const UI_FONT_SCALE_MAX = 150;
export const UI_FONT_SCALE_STEP = 5;
export const UI_FONT_SCALE_DEFAULT = 100;
const LEGACY_FONT_SCALE_IDS = {
sm: 88,
md: 100,
lg: 112,
xl: 125,
};
/**
* @deprecated 选壳已从主题菜单移除;壳气质跟呈现壳本身。保留常量以免旧引用报错。
*/
export const PRESENT_CHROMES = [ export const PRESENT_CHROMES = [
{ id: "default", name: "默认", description: "跟随壳本身" }, { id: "default", name: "默认", description: "跟随壳本身" },
{ id: "messenger", name: "讯息", description: "气泡对话感" }, { id: "messenger", name: "讯息", description: "气泡对话感" },
@@ -36,6 +51,7 @@ export const PRESENT_CHROMES = [
]; ];
const STORAGE_KEY = "wa-color-theme"; const STORAGE_KEY = "wa-color-theme";
const FONT_SCALE_STORAGE_KEY = "wa-ui-font-scale";
const CHROME_STORAGE_KEY = "wa-present-chrome"; const CHROME_STORAGE_KEY = "wa-present-chrome";
const THEME_IDS = new Set(COLOR_THEMES.map((t) => t.id)); const THEME_IDS = new Set(COLOR_THEMES.map((t) => t.id));
const CHROME_IDS = new Set(PRESENT_CHROMES.map((t) => t.id)); const CHROME_IDS = new Set(PRESENT_CHROMES.map((t) => t.id));
@@ -76,11 +92,72 @@ export function initColorTheme() {
return applyColorTheme(getStoredColorTheme()); return applyColorTheme(getStoredColorTheme());
} }
function clampFontPercent(n) {
const stepped = Math.round(n / UI_FONT_SCALE_STEP) * UI_FONT_SCALE_STEP;
return Math.min(
UI_FONT_SCALE_MAX,
Math.max(UI_FONT_SCALE_MIN, stepped),
);
}
/** 解析为百分比整数;兼容旧档 sm/md/lg/xl 与 0.x~2 倍缩放 */
export function normalizeUiFontScalePercent(raw) {
if (raw == null) return UI_FONT_SCALE_DEFAULT;
const key = String(raw).trim();
if (Object.prototype.hasOwnProperty.call(LEGACY_FONT_SCALE_IDS, key)) {
return LEGACY_FONT_SCALE_IDS[key];
}
let n = Number(key);
if (!Number.isFinite(n)) return UI_FONT_SCALE_DEFAULT;
if (n > 0 && n <= 2.5) n = Math.round(n * 100);
return clampFontPercent(n);
}
export function getStoredUiFontScale() {
try {
return normalizeUiFontScalePercent(localStorage.getItem(FONT_SCALE_STORAGE_KEY));
} catch {
return UI_FONT_SCALE_DEFAULT;
}
}
/** @deprecated 用 getStoredUiFontScale / applyUiFontScale(percent) */
export function fontScaleById(id) {
const percent = normalizeUiFontScalePercent(id);
return { id: String(percent), scale: percent / 100, name: `${percent}`, description: "" };
}
export function applyUiFontScale(raw) {
const percent = normalizeUiFontScalePercent(raw);
const root = document.documentElement;
root.setAttribute("data-ui-font-scale", String(percent));
root.style.setProperty("--ui-font-scale", String(percent / 100));
try {
localStorage.setItem(FONT_SCALE_STORAGE_KEY, String(percent));
} catch {
/* ignore */
}
return percent;
}
export function bumpUiFontScale(deltaSteps) {
const cur = getStoredUiFontScale();
const steps = Number(deltaSteps);
const delta = Number.isFinite(steps) ? steps : 0;
return applyUiFontScale(cur + delta * UI_FONT_SCALE_STEP);
}
export function initUiFontScale() {
return applyUiFontScale(getStoredUiFontScale());
}
/** @deprecated */
export function normalizePresentChrome(id) { export function normalizePresentChrome(id) {
const v = String(id || "").trim(); const v = String(id || "").trim();
return CHROME_IDS.has(v) ? v : DEFAULT_CHROME; return CHROME_IDS.has(v) ? v : DEFAULT_CHROME;
} }
/** @deprecated */
export function getStoredPresentChrome() { export function getStoredPresentChrome() {
try { try {
return normalizePresentChrome(localStorage.getItem(CHROME_STORAGE_KEY)); return normalizePresentChrome(localStorage.getItem(CHROME_STORAGE_KEY));
@@ -89,13 +166,15 @@ export function getStoredPresentChrome() {
} }
} }
/** @deprecated */
export function chromeById(id) { export function chromeById(id) {
const cid = normalizePresentChrome(id); const cid = normalizePresentChrome(id);
return PRESENT_CHROMES.find((t) => t.id === cid) || PRESENT_CHROMES[0]; return PRESENT_CHROMES.find((t) => t.id === cid) || PRESENT_CHROMES[0];
} }
export function applyPresentChrome(id) { /** @deprecated 菜单已移除;调用仍把属性钉为 default,避免旧存档残留 */
const chrome = normalizePresentChrome(id); export function applyPresentChrome(_id) {
const chrome = DEFAULT_CHROME;
document.documentElement.setAttribute("data-present-chrome", chrome); document.documentElement.setAttribute("data-present-chrome", chrome);
try { try {
localStorage.setItem(CHROME_STORAGE_KEY, chrome); localStorage.setItem(CHROME_STORAGE_KEY, chrome);
@@ -111,6 +190,7 @@ export function applyPresentChrome(id) {
return chrome; return chrome;
} }
/** @deprecated */
export function initPresentChrome() { export function initPresentChrome() {
return applyPresentChrome(getStoredPresentChrome()); return applyPresentChrome(DEFAULT_CHROME);
} }