引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 01:13:48 +08:00
co-authored by Cursor
parent ec474cb946
commit b82da4c5a5
95 changed files with 6781 additions and 1344 deletions
+2 -2
View File
@@ -12,12 +12,12 @@
| 叙事指南与故事推进 | **专用 mosaic**(风格与写法 \| 推进与决策) | `renderNarrativeBodyHtml` |
| 变量设计 | **结构化卡**(真值 / side_effects) | `renderVariableDesignHtml` |
| 变量控制上下文 | 通用 fragment | context-fragment |
| 正文组成 | 通用 fragment + 游玩期 **present 壳** | fragment;游玩 `present-shells.js` |
| 正文组成 | **专用卡**:present 壳渲染 `正文.示例.灌数`(看美化)+ 契约折叠 | `renderReplyFormatBodyHtml` |
| 设计监控栏 | 通用 fragment | context-fragment |
| 游玩拓扑 | **槽位芯片卡** | `renderPlaySlotsHtml` |
| 上下文投影排序 | **排序卡** | `renderContextOrderHtml` |
| 裁决包 settlement.v1 | **分节卡** | `renderSettlementSectionsHtml` |
| 开场白与开场变量 | 通用 fragment | context-fragment |
| 开场白与开场变量 | 专用:候选 swipe + 呈现壳 | `renderOpeningSetupBodyHtml` |
| 拓扑图谱 | (骨架未写) | — |
**说明:** 「通用 fragment」= 公共头(brief/mount/自评)+ 正文结构化树,已比生 JSON 友好;「专用」= 按技能排版的 mosaic/卡片。
+13 -6
View File
@@ -121,7 +121,8 @@
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] },
{ "id": "美学纲领与交互范式·改", "name": "美学纲领与交互范式", "mode": "revise", "revises": "美学纲领与交互范式", "depends_on": ["生成规则#2"] }
]
}
```
@@ -133,9 +134,13 @@
| `id` | 本局步骤唯一键;同技能多次必须不同 |
| `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) |
| `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) |
| `mode` | 可选。缺省 / `fresh` = 从零新建;`revise` = 回头修改,继承 `revises` 所指那步的既有产物 |
| `revises` | `mode=revise` 时必填:被改的既有步骤 id(须更前、同 name) |
| `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) |
程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次编入。
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改用 `mode=revise`,不要再排一条 fresh。
〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。
## 2.5 已合并技能(勿拆回)
@@ -326,6 +331,8 @@ boundary: |
|------|------|
| `declaration` | 插入编排提示,选型 |
| `name` / `artifact` | 流程与执行映射 |
| `kind` | 可选;节点特性。`prior-artifact` = 先验产物(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) |
| `params` | 可选;普通节点=编排参数。〔先验产物〕=规划产物字段(可提前填,空则步内钉) |
| `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 |
改 `name` 会破坏已有工作流计划 JSON,尽量不改。
@@ -462,12 +469,12 @@ artifact:
| 美学纲领与交互范式 | `aesthetics-interaction` | 范例 |
| 实现机制 | `mechanism` | 已写 |
| 舞台骨架 | `world-blueprint` | 已写(`context-fragment.v1`;社会结构 + 世界状况) |
| 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;合同严、正文可长) |
| 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;只按规则执行) |
| 生成规则 | `generation-rules` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;合同严、正文可长) |
| 具体实例 | `concrete-instances` | 已写(`context-fragment.v1`;可反复;〔先验产物〕;只按规则执行) |
| 叙事指南与故事推进 | `narrative` | 已写(`context-fragment.v1`;遣词/笔墨/禁忌+推进;旧称叙事指南) |
| 正文组成 | `reply-format` | 已写(用户可见版式+隐藏段+前端拆分;旧称设计回复格式) |
| 正文组成 | `reply-format` | 已写(壳适配 + 示例 present 预览看美化;旧称设计回复格式) |
| 设计监控栏 | `status-bar` | 已写(只盯会变信息;旧称设计状态栏) |
| 开场白与开场变量 | `opening-setup` | 已写(开场正文+初值;守正文组成) |
| 开场白与开场变量 | `opening-setup` | 已写(〔收口〕:1~多条开场;选定后落库并保存定稿) |
| 拓扑图谱 | `topology` | 待细写 |
| 变量设计与更新规则 | `variable-design` | 已写;见 progressive-data-design.md |
| 变量控制上下文 | `variable-context` | 已写 |
+5 -3
View File
@@ -57,7 +57,7 @@
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
设计监控栏:只监控会变字段(可含攻略目标/场景交互物);artifact=`设计.监控栏`(旧称状态栏)。
变量设计:真值 + **维护语句** + **Data映射索引**(指向具体实例)+ side_effects。
变量控制上下文:各槽视野 + **旁观汇总**;mount 含主世界层与旁观维护。
@@ -82,8 +82,8 @@ order 4: 用户.最新输入(full)
```json
{
"schema": "context-order.v1",
"brief": "扁平投影序",
"play_slots": { "gm": true, "narrator": true, "perspective": false },
"brief": "先名册再分配:旁观 + 主世界层 + 转述",
"play_slots": { "auditor": true, "gm": true, "narrator": true, "perspective": false },
"slots": [
{
"ref": "world-simulator",
@@ -99,6 +99,8 @@ order 4: 用户.最新输入(full)
}
```
创作期 LLM 也可先写 `agents[]`(`id` 为 `auditor` / `gm` / `narrator` / `perspective` / `chance`,含 `enabled` / `when` / `inserts`);程序会收成上表的 `slots` + `play_slots`。运行时仍读 `slots[].ref`(worker ref)。
| 字段 | 说明 |
|------|------|
| `order` | 每槽独立扁平数字序;**0 建议为人设** |
+4 -2
View File
@@ -36,9 +36,11 @@ Book 跨 Session:过程、资产、游玩
新建作品 → UI 选配方 → 用户首句
→ design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed)
→ 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能)→ closed
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**;已完成节点用回头修改继承旧稿)→ closed
→ 收成 设计.worker集(运行规格)→ 验收后才可进 play
→ (可选)opening-generator → 用户手动进游玩
→ 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance
→ (无开场白节点时可选)opening-generator 兜底落库
→ 用户手动进游玩
→ run:执行单元按契约从黑板重装;acceptance=review 处停、压缩过程 tag
```
+4 -3
View File
@@ -13,7 +13,7 @@
→ 按「要同时看见什么」选 shell_id(不是按好看)
→ 只改允许的微调轴
→ 禁止改分区骨架、禁止发明第八种布局
游玩期:往壳区域灌数;正文随 600~3000 字撑高滚动
游玩期:往壳区域灌数;正文随 500~2000 字撑高滚动
```
选壳依据(同时可见关系):
@@ -36,7 +36,7 @@
| 优先级 | 区域 | 视觉待遇 |
|--------|------|----------|
| **P0** | `body` | 阅读主轴;**随字数撑高**(单轮按 600~3000 字设计),由页面/消息区滚动 |
| **P0** | `body` | 阅读主轴;**随字数撑高**(单轮按 500~2000 字设计),由页面/消息区滚动 |
| **P1** | 建议行动 | 要决策时醒目;`choice_dock` 坞 **sticky 贴底** |
| **P2** | `monitor` | 芯片密排,高度固定矮 |
| **P3** | `header` / `footer` / `aside` | 辅助;双栏 **aside sticky**,长文仍可对照 |
@@ -88,6 +88,7 @@
正文.呈现壳.shell_id → 七选一
正文.呈现壳.微调 → 只填允许轴
正文.可见块[] → 挂到壳已有区域
正文.示例.灌数 → present.v1 形状预览(非正史;专供验收壳/美化)
设计.监控栏 → monitor 字段表
运行.本轮.裁决 → body 事实;suggested_actions → 行动区
@@ -130,4 +131,4 @@
---
修订:2026-08-13 · 七壳;明确信息层级与主面积。
修订:2026-08-22 · 七壳;终稿阅读柱;单轮正文 500~2000 字。
+60 -25
View File
@@ -216,39 +216,72 @@ type UnsupportedPresetSection = {
## 4. 上下文装配规则
一次请求的上下文装配按以下顺序执行:
运行时只走一份 `promptOrder`:遇到普通条目原样插入,遇到 marker 就填洞。空的酒馆角色卡 / 世界书 marker **不填我们的正文**(跳过)。
### 4.1 Marker 词汇表
酒馆原有(导入后保留位置,空洞跳过):
```text
1. 读取 PresetPackage.promptOrder。
worldInfoBefore personaDescription charDescription charPersonality
scenario worldInfoAfter dialogueExamples chatHistory
```
我们新增(由 context-order 填值;生成内容按 **世界书条目** 看待,不是角色卡字段):
| identifier | 填什么 |
|---|---|
| `worldBookBefore` | `对话.历史` 之前的 inserts,每条为标题+正文 |
| `chatHistory` | 共用酒馆 id;填 `对话.历史` |
| `worldBookAfter` | 历史之后、本轮之前(变量、工单等)。**不是** `worldInfoAfter` |
| `currentTurn` | `用户.最新输入`,role=user |
| `postTurn` | 本轮输入之后的 inserts(可空) |
`worldInfoBefore` / `worldInfoAfter` 都在酒馆历史 **前**,不能承接「我们写的、历史后」,因此不要复用那两个 id。
### 4.2 导入时插入四个洞
导入 SillyTavern `prompt_order` 时,以 `chatHistory` 为锚点切开并插入:
```text
[...ST 历史前条目...]
worldBookBefore
chatHistory
worldBookAfter
currentTurn
[...ST 历史后条目...]
postTurn
```
若原文没有 `chatHistory`,把它补在历史前条目之后,再接后四个洞。原生预设也可以直接按这份扩展 order 来写。无选用预设时,play worker 仍按现有 `assembleWorkerContext` 拼装(不走 prompt_order)。
社区预设里包着 WI 洞的装饰分隔会留在历史前段,中间可能是空的。第一版接受。
### 4.3 六段夹心(扫 order 后的效果)
```text
1. 酒馆内、历史前(ST 普通条目)
2. 我们写的、历史前(worldBookBefore)
3. 对话历史(chatHistory)
4. 我们写的、历史后本轮前(worldBookAfter)
5. 本轮对话(currentTurn)
6. 本轮对话后(ST 历史后条目 + postTurn)
```
### 4.4 装配步骤
```text
1. 读取(已扩展过的)PresetPackage.promptOrder。
2. 过滤 enabled=false 的顺序项。
3. 找到对应 PresetPromptEntry。
4. 再过滤 entry.enabled=false 的条目。
5. 对 marker 条目进行运行时替换。
6. 对普通 content 条目进行变量替换。
7. 按 role 合并成 LLM messages。
5. 对 marker:用 WorldInfoPack 解析我们的四个洞 + chatHistory;酒馆角色卡/WI 恒为空。
6. 普通 content 条目原样插入。
7. 按条目 role 合成 LLM messages。
8. 应用 generation 参数。
```
其中 marker 条目用于挂载运行时上下文:
```text
charDescription
可以映射为当前创作项目的设定说明。
charPersonality
可以映射为文风、叙事人称、角色行为约束。
worldInfoBefore / worldInfoAfter
可以映射为长期设定、世界观、知识库片段。
scenario
可以映射为当前创作目标或当前阶段说明。
chatHistory
可以映射为历史会话、已有正文、用户反馈摘要。
```
这些映射不是 SillyTavern 原义的完整复刻,而是为了兼容预设资产,让它们能服务我们的写作 agent。
不实现:世界书关键词、概率、sticky、Depth 插进历史中间、outlet、正则、插件脚本。
## 5. 与创作流程的关系
@@ -270,6 +303,8 @@ Runtime Session
因此,同一个 preset 可以用于多个创作流程;同一个创作流程也可以切换不同 preset。二者是正交关系。
生成参数可全局合并进 LLM 请求。**prompt 条目 + marker 填洞只在 play worker 上用**:创作步 / 总管 tool loop 不得把角色卡骨架套到 design-step 上,也不得在填洞后再叠一份相同的 user 上下文。
## 6. 第一版导入策略
第一版导入器只做保守转换:
+1 -1
View File
@@ -13,7 +13,7 @@
| kind | 名称 | 何时存 | 存什么 |
|------|------|--------|--------|
| **`instance`** | 创作定稿截面 | Worker 集 accept;可选含 **锁定开局** | `设计.worker集`、静态设定 tag、`运行.初始变量`、`输出.开场白`;**不含**后续轮次、事件流 |
| **`instance`** | 创作定稿截面 | Worker 集 accept;**选定开场后自动存**(也可手动) | `设计.worker集`、静态设定 tag、`运行.初始变量`、`输出.开场白`;**不含**后续轮次、事件流 |
| **`opening`** | 开局锁定(可选单独存) | 创作末尾 swipe 选定开场后 | 与 instance 中开局部分相同;便于「同 Worker 集、换开局再开 play 线」 |
| **`run`** | 游玩存档 | play 任意时刻 | instance/opening 层 + `运行.*`、轮次、变量当前值、对话 |
+3 -3
View File
@@ -35,7 +35,7 @@
**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改用**回头修改**(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
**剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成:
- **工作流计划** = 怎么排技能步骤(DAG)
@@ -100,7 +100,7 @@
|---------|----------|------|
| `design-flow` | **创作 · 流程编排** | 以已选【配方】为起点,编排/增量修订工作流计划(可变 DAG) |
| `design-step` | **创作 · 执行步骤** | 按工作流计划执行当前【技能】 |
| `opening-generator` | **开局 · 开场白** | 创作末尾可选 |
| `opening-generator` | **开局 · 开场白** | 无 `opening-setup` 时的兜底落库;有则选定开场白节点即收口 |
| `worker-spec`(技能) | **游玩拓扑** | 勾选固定槽;旧称「Worker 规格」,勿再当自由发明演员 |
| `design-core` 等 | (已废弃) | 旧分步 skill;勿再调度 |
@@ -182,7 +182,7 @@
| `intake` | 描述需求 | 发送 |
| `input` | 补充说明 | 发送 / 补充并发送 |
| `worker_questions` | 答题 | 补充并发送 |
| `approve_step` | 确认 | 同意并开始 |
| `approve_step` | 确认 | 同意并开始;回头修改时文案为「回头修改 · {技能名}」 |
| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
| `next_intent` | 说话 | 继续(可留空) |
| `revision` | 说话 | 按意见修改 |
+31 -13
View File
@@ -86,8 +86,11 @@ declaration: …
| 字段 | 作用 |
|------|------|
| `id` / `name` / `declaration` / `artifact` | 索引与产物映射;`declaration` 可被 prompt `meta` 覆盖 |
| `repeatable` | 可选;`true` = 允许同能力多次编入增量 DAG |
| `params` | 可选;编排期 `steps[].params` 声明(`key`/`label`/`required`/`hint`);必填项须在进执行前钉齐 |
| `repeatable` | 可选;`true` = 允许同能力多次**新建**编入增量 DAG(每次彻底新的,不改旧条) |
| `closer` | 可选;`true` = 创作终节点(选定后落库、关 DAG、保存定稿) |
| `auto` | 可选;`true` = 程序步(提案后直接执行) |
| `kind` | 可选;节点特性。缺省=普通执行步。`prior-artifact` = **先验产物**(须先定写什么;params 是规划产物,执行注入【规划产物】,不拦确认开干) |
| `params` | 可选;普通节点=编排期 `steps[].params` 声明(必填项须在进执行前钉齐)。〔先验产物〕=规划产物字段(可提前填,空则步内钉) |
| `opening` | 可选覆盖;一般只写在 prompt 的 `opening` 块 |
编排器注入【能力 · 可选工序】时,会读取各能力 `prompt.md` 的 `meta`(`declaration` / `when` / `when_not` / `boundary`),**不是**只看 catalog 短声明。执行全文仍只在 design-step 注入。
@@ -104,6 +107,15 @@ declaration: …
配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。
### 节点特性(`kind`)
节点特性是 catalog 上的固定系统,不是某一步的特例。缺省为普通执行步;后续会加更多 kind。
| kind | 中文 | 行为 |
|------|------|------|
| (缺省) | 普通执行步 | 有必填 params 时,确认开干前钉齐 |
| `prior-artifact` | 先验产物 | 须先定「写什么」(生成规则、具体实例)。编排器可提前把规划写入 `params`——规划多条是正确的。执行时注入【规划产物】;规划可空,步内钉/修订。不拦确认开干 |
### 节点循环(通用)
```text
@@ -113,15 +125,21 @@ declaration: …
→ 用户选选项 / 写意见 →「按意见修改」
(产物 + 追问 + 选项 + 意见 + 节点要求一并重跑)→ 新产物
→ 循环直到「接受」
→「下一步想写什么」(可留空,交给工作流计划 DAG)
→ 展示下一节点并确认开干(可在此钉编排参数,如生成规则的 target)
→ 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行)
· 可反复技能再来一次 = 彻底新建(新 id / 新 params)
· 已完成节点要改 = 回头修改(mode=revise,继承旧产物)
→ 下一节点 opening / design-step …
直到创作收口
直到「开场白与开场变量」(〔收口〕)
→ 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿
```
「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。
有编排参数的能力:可在确认开干时钉齐 params;勿把「生成什么」拖到执行中途才问。
「回头修改」是编排层能力:给已完成步骤追加一条 `mode=revise` 的新步,执行时注入既有产物继续改。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,用回头修改,不必整局重开。
〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。
有编排参数的**普通**能力:可在确认开干时钉齐 params。
---
@@ -140,15 +158,15 @@ recipes/world-simulator|expand-assistant/recipe.yaml
| 美学纲领与交互范式 | `aesthetics-interaction` | **范例已写**;前端 mosaic 视图 |
| 实现机制 | `mechanism` | **已写**:`context-fragment.v1`(支撑点正文 + 自评 + 追问);`feeds: gm`;**专用卡已接** |
| 舞台骨架 | `world-blueprint` | **已写**:`context-fragment.v1`;社会结构 + 世界状况;**专用卡已接** |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;宽原则 + 严合同键;可反复;`params.target`;挂 gm+auditor;**专用卡已接** |
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;`params.rule_id` |
| 生成规则 | `generation-rules` | **已写**:`context-fragment.v1`;宽原则 + 严合同键;可反复;**〔先验产物〕**;规划字段 `target`;挂 gm+auditor;**专用卡已接** |
| 具体实例 | `concrete-instances` | **已写**:`context-fragment.v1`;只按规则执行;可反复;**〔先验产物〕**;规划字段 `rule_id` |
| 叙事指南与故事推进 | `narrative` | **已写**:一份全文双挂;**不**为省 token 拆投影;结构化卡 |
| 拓扑图谱 | `topology` | 骨架,待细写(拟改为选项小循环或降级) |
| 设计监控栏 | `status-bar` | **已写**:只盯会变信息;通用 fragment 卡 |
| 正文组成 | `reply-format` | **已写**:壳适配;P2 present 渲染已接 |
| 正文组成 | `reply-format` | **已写**:壳适配 + 正文.`示例`(present.v1 预览,看美化);P2 present 渲染已接 |
| 变量设计与更新规则 | `variable-design` | **已写**;专用/结构化卡 |
| 变量控制上下文 | `variable-context` | **已写**:旁观汇总;mount 含 auditor;通用 fragment 卡 |
| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值 |
| 开场白与开场变量 | `opening-setup` | **已写**:开场守版式+同真相初值;**〔收口〕**选定后落库并保存定稿 |
共用收成(池内保留,按需):
@@ -170,6 +188,6 @@ recipes/world-simulator|expand-assistant/recipe.yaml
1. 只选配方 → 出**近期**创作流程(`status=open`)
2. design-step 能切割出 `opening`/`task`/…
3. 有 `opening` 时先程序开场再 LLM
4. 可追加同能力多次(不同 step.id + params);收成前 `status=closed`
5. UI 用编排术语;有编排参数的步骤须展示 params
6. 缺必填 params 的步骤不得视为可执行(校验失败 / 编排先 askUser)
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);也可 `mode=revise` 回头修改已完成节点(继承旧产物);收成前 `status=closed`
5. UI 用编排术语;有编排参数的步骤须展示 params;回头修改在流程上标「回头修改」,确认开干文案与「接下来生成」区分
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;回头修改可继承原步 params
+1 -1
View File
@@ -25,7 +25,7 @@ worker-templates/
```text
选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open)
→ 反复 design-step(注入 modules/{id}/prompt.md)
→ 不够则再 design-flow(可追加 repeatable 能力)→ closed
→ 不够则再 design-flow(可追加 repeatable 新建,或 mode=revise 回头修改)→ closed
→ (可选)opening-generator → 手动进 play
```
@@ -76,28 +76,15 @@ feeds: design_only
## probe
```probe
用户首句或黑板已有的直接采纳。一次只在产物「追问」写 1~2 点(建议选项 + 示例);顶层 askUser 留 null,不要同一问写两遍。
已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。
本步只钉三件:站位(代入 / 镜头看哪 / 爽从哪来)、核心感觉(反复感受到什么)、轮转边界(何时等用户)。
【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。
若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。
用户首句或黑板已有的直接采纳。默认问题已覆盖变造、代入、核心感觉;开场若已跳过,把「用户.需求」首句当答复。已答清的不重问。
【参与 · 结构性】
- 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色
- 输入解释:主视角行动 vs 世界行动;正文人称(第二人称 / 上帝 / 第一人称…)
- 焦点位置:自己 / 人物 / 群像 / 关系 / 世界规则 / 感觉 / 叙事
- 满足来源:感同身受 / 互动 / 观察 / 掌控 / 创作质量
完备:能判断用户站哪、镜头看哪、爽从哪来。
缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短场景);顶层 askUser 为 null。三件够验收:`追问.题目` 为 `[]`,内容维度等其余键写「未定」或省略。
【内容 · 深度随焦点】
核心感觉;人物与关系;身体与感官;背景与规则(宜短);意义与主题。
变造已定位的,只挖服务核心体验的部分。
用户讲人物、舞台、规则:只抽取能改这三件的一句写入正文,其余写入「开放问题」,本步不追问细节。
【轮转】
思考与抉择、言语:何时等、怎么等、例外、输入后怎么补(只确认/补细节/补心理/连带后果)。
【收成三块】
体验内核(含形状:过程/张力/层次/构成/循环);呈现要点;边界禁忌。
用两种站位或两种感觉的短场景让用户选或改,不追问开放式「你还想要什么」。
```
## output
@@ -4,8 +4,13 @@
# name = 固定中文名(流程 JSON 的 name;同能力可多次时靠 step.id 区分)
# declaration = catalog 短声明(可被 prompt.md ```meta 覆盖)
# artifact = 执行期产物 tag(程序映射;不写进流程 JSON)
# repeatable = true 时允许同能力多次编入增量 DAG(如生成规则、具体实例)
# params = 可选;编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐)
# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。
# 回头修改已完成节点不是 repeatable:DAG 用 mode=revise + revises,继承旧产物。
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收)
# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行)
# params = 可选;普通节点=编排进 DAG 时 steps[].params 的声明(required 须在执行前钉齐)
# 〔先验产物〕=规划产物字段(可提前填,空则步内钉)
# opening = 可选;覆盖 prompt.md 里 ```opening 默认问题(一般只写在 prompt.md)
#
# 编排选型:程序会读取各 modules/{id}/prompt.md 的 meta
@@ -15,8 +20,10 @@
# 后来写能力:同结构 prompt.md(含默认问题)+ 本表一行索引
#
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力。
# 有编排参数的能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。
# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。
# 已完成节点要改:编排 mode=revise(继承旧稿),不要再排一条 fresh。
# 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。
# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。
modules:
# —— 体验契约 ——
@@ -37,6 +44,7 @@ modules:
- id: generation-rules
name: 生成规则
kind: prior-artifact
repeatable: true
declaration: >-
仅当某类内容既重要又无法凭基底稳定生成时:写出规则
@@ -45,19 +53,17 @@ modules:
params:
- key: target
label: 生成对象
required: true
hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它;用户改对象时必须同步改 rule_id 与步骤 id
hint: 规划字段;编排器已知写哪一类就填。空则步内钉
- key: rule_id
label: 规则 id
required: false
hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants,丧尸→zombies);改对象时禁止沿用旧 id
hint: 英文 kebab-case,须与 target 对应(女租客→female-tenants);改对象时禁止沿用旧 id
- key: lifecycle_intent
label: 生命周期意图
required: false
hint: runtime_only | seed_only | seed_and_runtime;不明则 askUser
hint: runtime_only | seed_only | seed_and_runtime;不明则步内钉
- id: concrete-instances
name: 具体实例
kind: prior-artifact
repeatable: true
declaration: >-
按预生成规则原样产出实际产物;不设计、不改合同;可按 rule_id/批次反复调用
@@ -65,15 +71,12 @@ modules:
params:
- key: rule_id
label: 调用规则
required: true
hint: 必须来自已验收生成规则中 seed_only/seed_and_runtime 的真实 rule_id;编排时给选项
hint: 规划字段;须来自已验收生成规则中 seed_only/seed_and_runtime 的 rule_id。空则步内钉
- key: batch_goal
label: 批次目标
required: false
hint: 本批要锚定什么(如开局重要NPC)
- key: count
label: 数量
required: false
hint: 数字或「按规则」;contextual 时写清规模依据
- id: narrative
@@ -127,9 +130,10 @@ modules:
# —— 开局(创作偏晚) ——
- id: opening-setup
name: 开场白与开场变量
closer: true
declaration: >-
写出遵循正文组成/叙事等契约的开场一小段,并钉同真相的开场变量初值;
开场为主、填表为辅;可与 opening-generator 落库
创作终节点:写出 1~多条开场白并钉同真相初值;
选定后程序落库、结束创作并保存定稿
artifact: 设计.开场白与开场变量
# —— 收成 / 钉声明(共用;世界模拟器按需) ——
@@ -144,9 +148,10 @@ modules:
- id: context-order
name: 上下文投影排序
repeatable: false
auto: true
declaration: >-
根据各上游上下文概括,为启用槽写出扁平投影序(含对话.历史与 projection);
挂谁已定、排第几在本步;不发明执行单元、不重写长文
先抄固定执行单元名册,再为每个已启用者写出扁平投影序
(含对话.历史与 projection);不发明槽、不重写长文、不演情节选择题
artifact: 设计.上下文投影排序
- id: refine
@@ -27,7 +27,11 @@ feeds: gm
## opening
```opening
规则已由【本步参数】rule_id 钉死。有批次目标或数量偏好可一句带过;否则直接「按规则生成」。
先认定本批按哪条规则生成:
- 若【规划产物】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
- 若规划为空,从已验收生成规则里选出一条预生成规则。
有批次目标或数量偏好可一句带过;否则直接「按规则生成」。
```
## task
@@ -37,9 +41,11 @@ feeds: gm
本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【规划产物】当起点;规划可空。
执行顺序:
1. 取出 `params.rule_id` 对应规则;非预生成生命周期或缺信息 → 停止并指出缺口。
2. 数量:`params.count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。
1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。
2. 数量:规划 `count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(单层 JSON;键=schema 顶层字段)。
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。
5. 输出 JSON;追问通常 `[]`。
@@ -72,9 +78,9 @@ summary:`具体实例 · {对象} · {n}条`
"mount": ["world-simulator"],
"稳变": "stable",
"正文": {
"rule_id": "与 params 一致",
"rule_id": "与本步认定一致(规划有则对齐)",
"本批数量": 3,
"批次条件": ["params.batch_goal 等;无则 []"],
"批次条件": ["规划 batch_goal 等;无则 []"],
"batch_id": "英文 kebab-case",
"records": [
{
@@ -111,7 +117,7 @@ summary:`具体实例 · {对象} · {n}条`
## checklist
```checklist
- [ ] 按 params.rule_id 的预生成规则执行?
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
- [ ] records 单层、键值符合产物格式与全部约束?
- [ ] 自评为合规模?未改合同?
```
@@ -1,8 +1,9 @@
# 上下文投影排序
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> **本步只排出扁平投影序,不发明执行单元,不重写设定长文。**
> 「对话.历史」也是可投影标签(改 projection 裁剪长度);见 `docs/context-fragment-design.md`。
> **先抄程序钉死的执行单元名册,再给每个已启用者分配上下文与顺序。**
> 不发明执行单元,不重写设定长文,不演游玩选项。
> 「对话.历史」也是可投影标签;见 `docs/context-fragment-design.md`。
## meta
@@ -11,36 +12,32 @@ name: 上下文投影排序
id: context-order
artifact: 设计.上下文投影排序
declaration: >
根据各上游上下文的概括,为每个启用槽写出扁平投影序
(含对话.历史标签与 projection);不发明槽、不重写正文
先列出本局固定执行单元(程序名册),再为每个已启用者写出扁平投影序
(含对话.历史与 projection);不发明槽、不重写正文、不演情节选择题
when: |
游玩槽已确认(或可按默认),且主要上下文块已有可摘要产物,
需要决定插入顺序与投影级别、再收成运行规格时。
需要决定各执行单元看见什么、以何顺序插入时。
when_not: |
体验/站位仍混沌 → 先美学纲领与交互范式。
槽位未定且用户拒绝默认 → 先游玩拓扑。
上游上下文仍大量空洞、无法判断稳/变 → 先补缺口,勿空排。
只需勾选槽 → 游玩拓扑;只需合并终稿 → 细化终稿。
boundary: |
本技能:读各块概括 → 产出每槽 inserts[](order / ref / projection / 可选 note);须含对话.历史。
无「历史前/后」硬分区;对话.历史本身是可排序的一项。
游玩拓扑:槽位权威;本步不增白名单外 ref(含按需 chance 不进每轮序)。
中段技能:可带 mount 与稳/变信号,最终 order 以本步为准。
本技能:抄名册 → 凭各块 brief/挂载/稳变 给每槽 inserts[]。
名册权威在程序(及已勾选的游玩拓扑);本步不增白名单外 id。
中段技能可带 mount 与稳/变;最终 order 以本步为准。
细化终稿:把本步排序表收进运行规格 context_order;本步不输出完整 设计.worker集。
禁止把依赖产物里的游玩示例(迎上去/推开/反客为主 等)当成当前要问的题。
feeds: design_only
```
## opening
```opening
准备排出各游玩槽的扁平投影序(只排序,不改设定正文)。
按已验收产物的概括,给每个固定执行单元分配上下文顺序(只排序,不改设定、不演游玩)。
请确认或补充:
1. 启用槽是否仍是默认?(世界模拟:主世界层 + 叙事转述;角色视角默认关)
2. 「对话.历史」希望夹在哪、投影多狠?(默认 summary)
3. 有没有「几乎永不改」或「每轮必变」的块要特别强调?
若前面产物够用,可直接回复「按已有概括排序」。
名册由程序钉死:旁观维护(表格)/ 主世界层 / 叙事转述 / 可选角色视角 / 按需机遇。
若前面产物够用,直接回复「按已有概括排序」。
```
## task
@@ -48,40 +45,45 @@ feeds: design_only
```task
你正在执行「上下文投影排序」。产物写入「设计.上下文投影排序」。
输入:已启用槽;各上游上下文的短摘要(及 mount / 稳变信号,若有)。
输出:按槽分组的格式化排序表——Runtime/终稿据此把投影插到固定位置。
执行顺序(必须按此,禁止颠倒):
1. **抄名册**:以程序注入的【本局固定执行单元】为准列出 agents[](id / label / enabled / when / duty)。
启用与否跟已勾选游玩拓扑走;未勾选则按默认:旁观+主世界层+转述开,角色视角关,机遇关。
2. **再分配**:只对 enabled 的每轮槽写 inserts(order / ref / projection / 可选 note)。
凭上游 **brief、挂载、稳变** 排序,禁止把百科全文再读一遍当创作。
规则:
1. 禁止发明执行单元或白名单外 ref。
2. 禁止重写上游长文;只写 order、ref、projection 与一句 note(勿再写硬锚点分区)。
3. 每槽扁平数字序:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
4. 必须包含一项 ref=`对话.历史`(默认 projection=summary);历史由程序维护,本步只决定夹在哪、投影多狠。
5. 常见块名用新 artifact:设计.舞台骨架 / 设计.叙事指南与故事推进 / 设计.正文组成 / 设计.监控栏 等。
1. 禁止发明白名单外 id(含画图等未入册槽)。
2. 禁止重写上游长文;只写顺序与投影。
3. 每轮槽:order 0=该槽固定人设;其后按「稳→对话.历史→易变」软排。
4. 主世界层与转述必须含 ref=`对话.历史`(默认 projection=summary);旁观维护默认不含历史。
5. 按需槽(机遇)enabled 时 inserts 可空。
6. 越稳越靠前;本轮输入/真值/裁决通常在历史之后。
7. 同一内容可挂多槽;chance 为按需槽,不必排进每轮 inserts。
8. summary:如 `投影排序 · 主世界层 N 条 · 含历史`。
7. 同一内容可挂多槽;chance 不必排进每轮 inserts。
8. summary:如 `投影排序 · 旁观+主世界层+转述`。
若程序已发默认问题:禁止重复同一开场;在首答上产出表。
若程序已发默认问题:禁止重复同一开场;用户说「按已有概括排序」或等价同意 → **立刻产出表**,禁止再问情节内选项。
禁止 askUser 出游玩选择题(迎上去/推开她/反客为主 等)。材料已齐则顶层 askUser 为 null。
```
## principles
```principles
1. 晚排序:此时才能区分常变与重要;勿回退去发明新槽。
2. 概括驱动:凭摘要与稳变信号排序,不把百科全文再读一遍当创作。
3. 历史是标签:用投影裁剪,不要假装「整段历史永远在中间锚点」。
4. 缓存优先:少变块靠前;高频变块靠后。
5. 投影分级:full / summary / fields / fixed——历史默认 summary。
6. 可修订已有排序表;用户改稳变判断时只改序,不改上游产物 id。
1. 名册先行:先有哪些执行单元,再谈每单元看什么。
2. 晚排序:此时才能区分常变与重要;勿回退去发明新槽。
3. 概括驱动:凭摘要与稳变信号排序,不把设定长文再当创作。
4. 历史是标签:用投影裁剪;旁观维护默认不进对话.历史。
5. 缓存优先:少变块靠前;高频变块靠后。
6. 投影分级:full / summary / fields / fixed——历史默认 summary。
7. 可修订已有排序表;用户改稳变判断时只改序,不改上游产物 id。
```
## probe
```probe
一次 askUser 1~2 点,优先 options:
- 某块稳/变判断冲突时,问用户更偏「少改利缓存」还是「每轮必须看见」。
一次 askUser 1~2 点,且必须关于「挂哪槽 / 稳还是变 / 投影级别」:
- 某块稳/变判断冲突时,问更偏「少改利缓存」还是「每轮必须看见」。
- 角色视角若开启,问哪些块禁止进主世界层。
已齐则直接产出,勿为凑问题而问。
已齐则直接产出,勿为凑问题而问。禁止问剧情里发生什么、谁敲门、玩家选哪一招。
```
## output
@@ -89,29 +91,64 @@ feeds: design_only
```output
{
"schema": "context-order.v1",
"brief": "一句话:本局扁平投影序(含对话.历史)",
"play_slots": { "gm": true, "narrator": true, "perspective": false },
"slots": [
"brief": "一句话:本局有哪些执行单元、各自上下文怎么排",
"agents": [
{
"ref": "world-simulator",
"label": "主世界层",
"id": "auditor",
"label": "旁观维护",
"enabled": true,
"when": "every_turn",
"duty": "查表/补规则,不写正文、不写真相",
"inserts": [
{ "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "槽位人设" },
{ "order": 1, "ref": "设计.实现机制", "projection": "summary", "note": "承重规则摘要" },
{ "order": 2, "ref": "对话.历史", "projection": "summary", "note": "按投影裁剪" },
{ "order": 3, "ref": "变量.当前", "projection": "fields", "note": "真值" },
{ "order": 4, "ref": "用户.最新输入", "projection": "full", "note": "本轮" }
{ "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "人设" },
{ "order": 1, "ref": "设计.变量设计与更新规则", "projection": "summary" },
{ "order": 2, "ref": "设计.生成规则", "projection": "summary" },
{ "order": 3, "ref": "变量.当前", "projection": "fields" },
{ "order": 4, "ref": "用户.最新输入", "projection": "full" }
]
},
{
"ref": "narrator",
"label": "叙事转述",
"id": "gm",
"label": "主世界层",
"enabled": true,
"when": "every_turn",
"duty": "读真值与规则,出裁决包",
"inserts": [
{ "order": 0, "ref": "worker.persona", "projection": "fixed", "note": "转述人设" },
{ "order": 0, "ref": "worker.persona", "projection": "fixed" },
{ "order": 1, "ref": "设计.舞台骨架", "projection": "summary" },
{ "order": 2, "ref": "对话.历史", "projection": "summary" },
{ "order": 3, "ref": "变量.当前", "projection": "fields" },
{ "order": 4, "ref": "用户.最新输入", "projection": "full" }
]
},
{
"id": "narrator",
"label": "叙事转述",
"enabled": true,
"when": "every_turn",
"duty": "只把裁决写成用户可见正文",
"inserts": [
{ "order": 0, "ref": "worker.persona", "projection": "fixed" },
{ "order": 1, "ref": "设计.叙事指南与故事推进", "projection": "summary" },
{ "order": 2, "ref": "对话.历史", "projection": "summary" },
{ "order": 3, "ref": "运行.本轮.裁决", "projection": "full", "note": "settlement" }
{ "order": 3, "ref": "运行.本轮.裁决", "projection": "full" }
]
},
{
"id": "perspective",
"label": "角色视角",
"enabled": false,
"when": "every_turn",
"duty": "强信息隔离时出反应建议",
"inserts": []
},
{
"id": "chance",
"label": "机遇裁定",
"enabled": false,
"when": "on_demand",
"duty": "骰子/抽签;按需调用,不进每轮",
"inserts": []
}
]
}
@@ -120,10 +157,12 @@ feeds: design_only
## checklist
```checklist
- [ ] agents 只使用程序名册 id(auditor / gm / narrator / perspective / chance)
- [ ] 未发明白名单外执行单元
- [ ] 未重写上游长文,只有排序/投影
- [ ] 每启用槽有 order 0 人设位
- [ ] 每槽含「对话.历史」且有合理 projection
- [ ] 未把游玩选项示例当成当前提问
- [ ] 已启用的每轮槽有 order 0 人设位
- [ ] 主世界层与转述含「对话.历史」;旁观维护默认无历史
- [ ] 少变块整体靠前、本轮/真值靠后(软规则)
- [ ] 与游玩拓扑勾选一致
```
@@ -36,8 +36,11 @@ feeds: gm,auditor
## opening
```opening
本轮生成对象已由编排参数钉死(见【本步参数】target)。请直接谈该类内容:
先认定本轮规则写哪一类内容:
- 若【规划产物】已有对象,以此为起点;要改对象就在本步改。
- 若规划为空,先钉对象族,再谈规则。
然后谈:
1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果?
2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
3. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
@@ -56,19 +59,19 @@ feeds: gm,auditor
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举;
一条规则可多个池;无则 `池: []`。
对象族已由【本步参数】`target` 钉死;禁止再问「这一步生成什么」,禁止顺手扩到其它族。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【规划产物】当起点;规划可空。禁止顺手扩到其它族。
执行顺序:
1. 读 params 与依赖。缺 `target` → 停止并提示补参。
1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。
2. 双门槛:重要性 + 基础上下文不足。任一项不成立 → 结论「无需专门规则」,`rules: []`。
3. 两项成立时,生命周期三选一(params.lifecycle_intent 合理则采用):
3. 两项成立时,生命周期三选一(规划里 `lifecycle_intent` 合理则采用):
- `runtime_only`:仅游玩期动态生成;不要求「具体实例」。
- `seed_only`:由「具体实例」预生成;规则用完可丢。
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
6. `rule_id`:params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与 params 不一致(用户中途改了对象),以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
6. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。
@@ -93,7 +96,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
## probe
```probe
每轮 1~2 点。禁止重问 params 已钉的 target / rule_id。
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
若双门槛不成立 → 建议跳过,停追问字段与格式。
@@ -111,9 +114,9 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
"稳变": "stable",
"正文": {
"本步参数": {
"target": "与 params 一致",
"target": "与本步认定的对象一致(规划有则对齐,步内改则以最新为准)",
"rule_id": "已钉或本步拟定;跳过时可空字符串",
"lifecycle_intent": "params 原值或空"
"lifecycle_intent": "规划原值、本步钉死值或空"
},
"必要性判断": {
"生成对象": "本轮对象族",
@@ -32,7 +32,7 @@ feeds: narrator,gm
美学里的感觉已有方向时,请谈「正文怎么写、故事怎么往前」(想到什么写什么):
1. 像什么文风?遣词什么味道?笔墨花在谁身上?有什么绝对不能写?
2. 故事怎么推进?要不要跟好感/进度挂钩?有没有情境要换一套写法?
2. 故事往前靠什么:场面自己推、等用户决策,还是无专门推进?正文若有几种写法档要切换(官能 / 爽文 / 恐怖 / 情色这类),点名即可;没有就说没有。
```
## task
@@ -45,9 +45,10 @@ mount:`narrator` 与 `world-simulator` 都挂同一份全文——不要为省
执行顺序:
1. 继承美学体验与禁忌,改写成做法向句子。
2. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好;需要时写情境备用。
3. 写推进与决策(可写「无专门推进」);变量只写钩子。
4. 自评 + 追问。
2. 写纲领、遣词(含示范)、笔墨焦点、禁忌/不偏好。
3. 情境备用 = 写法档(体裁/调性容器),不是具体桥段。有切换则每条写:名字=档名(如「官能小说」「网文爽文」「展示恐怖」「展示情色」),用法=该档怎么遣词、笔墨落哪、何时切进来。无切换则 `[]`。用户若报出「当众折辱」「独处一室」这类场合,升一档写进对应写法档,场合本身不单列。
4. 写推进与决策(可写「无专门推进」);变量只写钩子。
5. 自评 + 追问。
summary:`叙事指南与故事推进 · …`
```
@@ -58,14 +59,21 @@ summary:`叙事指南与故事推进 · …`
1. 感觉 vs 做法:美学写要什么爽;本步写怎么遣词、笔墨落哪、故事怎么推。
2. 遣词与禁忌要可执行;绝对禁忌 ≠ 不偏好。
3. 推进可轻可无;表规格交给变量/生成规则。
4. 整份双挂即可;禁止拆成两个技能。
4. 情境备用是写法档:名字用「官能小说」「网文爽文」「展示恐怖」「展示情色」同级;用法写该档遣词与何时切入。无切换则空数组。
5. 整份双挂即可;禁止拆成两个技能。
```
## probe
```probe
每轮 1~2 点。已在美学写清的感觉勿重问。
优先:体裁遣词、笔墨详谁、禁忌、推进门控。
本步只钉四件:遣词可执行、笔墨落点、绝对禁忌、推进方式(场面推进 / 用户决策 / 无专门推进)。
已在美学写清的感觉改写成做法向句子,不重问「想感受到什么」。
缺其中一件:在产物「追问」写 1~2 点(建议选项 + 短句示例)。四件够验收:`追问.题目` 为 `[]`。推进方式不明则正文写「无专门推进」,不问好感公式、进度表或事件池。
用户谈到变量增减、生成规则、舞台百科:只抽取能改遣词 / 禁忌 / 推进方式的一句,其余写入「开放问题」。
用户提到要换写法:收成写法档写入「情境备用」(官能 / 爽文 / 恐怖 / 情色这类),不追问具体场合怎么摆;没提则 `[]`。
```
## output
@@ -99,7 +107,16 @@ summary:`叙事指南与故事推进 · …`
"绝对禁忌": ["…"],
"不偏好": ["…"]
},
"情境备用": [],
"情境备用": [
{
"名字": "展示情色",
"用法": "切到身体与权力交换时:感官词加重、节奏放慢、对话变短;切回日常则收。"
},
{
"名字": "网文爽文",
"用法": "打脸/反转时:可复述对方原话、结果写满、过程可略。"
}
],
"推进与决策": {
"有无专门推进": "有|无",
"驱动力来源": "…",
@@ -126,18 +143,20 @@ summary:`叙事指南与故事推进 · …`
```
硬规则:合法 JSON;示范至少 1 段;禁忌与不偏好都要有;双挂;禁止 order;summary 见 task。
情境备用若有:名字必须是写法档(官能小说 / 网文爽文 / 展示恐怖 / 展示情色 同级),用法写该档的遣词与切入口;不是「当众折辱」「物资告急」这类具体桥段。无切换则 `[]`。
## checklist
```checklist
- [ ] 一份产物同时服务转述与主世界层?
- [ ] 遣词+示范+禁忌够用?推进清楚或明确无?
- [ ] 情境备用若有,是否是写法档(官能/爽文/恐怖/情色同级)而非具体桥段?无切换是否为 `[]`?
- [ ] 未为省 token 拆两套投影?
```
## examples
```examples
好:一次问清爽文遣词 + 好感门控倒追,整份挂两边。
坏:拆成「文风技能」和「推进技能」两步;或为省 token 给两边裁成两套残缺稿。
好:一次钉清遣词 + 笔墨落点 + 禁忌;推进写「等用户决策」或「无专门推进」;情境备用是「展示情色」「网文爽文」两档及切换时笔墨。
坏:拆成「文风技能」和「推进技能」两步;追问好感公式 / 事件池;或把「当众折辱」「独处一室」当成情境备用。
```
@@ -2,8 +2,8 @@
> 技能文档。程序只切割下方 **fence 块**;`##` 标题仅供人读。
> 产物外壳:`docs/context-fragment-design.md`。
> 创作偏晚:规格与呈现契约已齐,写「迈进世界的第一拍」并钉开场真值。
> 磁盘 worker `opening-generator` 可执行/落库本产物到 `输出.开场白` 与 `运行.初始变量`。
> **创作终节点**:规格与呈现契约已齐。写出 1~多条开场白供选定;选定后程序落库、关闭工作流、保存定稿。
> 不必再调磁盘 worker `opening-generator`(无本步时它仍可作兜底落库)。
## meta
@@ -12,20 +12,21 @@ name: 开场白与开场变量
id: opening-setup
artifact: 设计.开场白与开场变量
declaration: >
写出遵循正文组成/叙事/监控栏等契约的开场一小段,并钉与之同真相的开场变量初值;
开场为主、填表为辅
创作终节点:写出 1~多条遵循正文组成/叙事/监控栏契约的开场白,并钉与之同真相的开场变量初值;
选定后程序落库、结束创作并保存定稿
when: |
体验、舞台、叙事、变量(若有)、正文组成等已大致可引用,
需要可开玩的第一段剧情与初始真值快照时;通常在细化终稿前后、进游玩前。
体验、舞台、叙事、变量(若有)、正文组成、细化终稿等已大致可引用,
需要可开玩的第一段剧情与初始真值快照时;排在收成链之后,作为最后一步。
when_not: |
体验/呈现仍混沌 → 先美学与正文组成等。
只要改文风不要开场 → 叙事指南。
只要改真值规则不要开场文 → 变量设计。
尚未细化终稿、Worker 集未齐 → 先收成。
boundary: |
本技能:开场正文(按正文组成块序拼出用户可见第一屏)+ 开场变量初值 + 可选监控栏初值示意。
本技能是创作终节点:1~多条开场正文(按正文组成块序)+ 开场变量初值 + 可选监控栏初值示意。
必须遵守已验收的叙事指南、正文组成、监控栏、变量设计;不重做这些契约。
具体实例:开场可引用已有实例名片,不在本步新造百科。
opening-generator:可将本产物写入 输出.开场白 / 运行.初始变量 / 变量.当前。
选定后由程序写入 输出.开场白 / 运行.初始变量 / 变量.当前,关闭 DAG 并保存定稿;不要在本步之后再排其它能力。
feeds: narrator
```
@@ -42,19 +43,20 @@ feeds: narrator
## task
```task
你正在执行「开场白与开场变量」。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。
你正在执行「开场白与开场变量」。这是**创作最后一步**。产物必须是 **context-fragment.v1** JSON,写入「设计.开场白与开场变量」。
主产物是**开场一小段剧情**(用户第一屏),不是填表工具。
主产物是 **1~多条开场剧情**(用户第一屏),不是填表工具。用户将选定其中一条;选定后程序结束创作并保存。
必须遵循:叙事指南与故事推进、正文组成(可见块序、隐藏段若开场就要写)、监控栏字段(若启用)、变量真值名单。
执行顺序:
1. 读依赖:worker 集/美学/叙事/正文组成/监控栏/变量设计/舞台与实例。
2. 按正文组成的可见块写出开场各块内容(至少正文块);隐藏段仅当开场即需声明初值变更时使用(通常初值走「开场变量」字段,隐藏段可空)。
3. 钉开场变量:与开场事实同一真相;能从设定推断的填上;冲突则以开场叙述为准并改表。
4. 若有监控栏:给出开场时监控栏展示快照(短)。
5. 自评 + 追问;输出 JSON。
2. 写出 **1~3 条**可开玩的开场白候选(角度/第一拍可不同,设定必须同一真相)。推荐那条写入 `开场白全文`,全部写入 `开场白候选`。
3. 按正文组成的可见块写出开场各块内容(至少正文块);隐藏段仅当开场即需声明初值变更时使用(通常初值走「开场变量」字段,隐藏段可空)。
4. 钉开场变量:与开场事实同一真相;能从设定推断的填上;冲突则以开场叙述为准并改表。
5. 若有监控栏:给出开场时监控栏展示快照(短)。
6. 自评 + 追问;输出 JSON。
禁止:先问卷填表再糊开场;禁止开场与初值打架;禁止重写 Worker 集。
禁止:先问卷填表再糊开场;禁止开场与初值打架;禁止重写 Worker 集;禁止只交空壳等下一节点。
summary:`开场白与开场变量 · …`(点题场景)。
```
@@ -95,7 +97,12 @@ summary:`开场白与开场变量 · …`(点题场景)。
"内容": "该块开场文本;可空块写空字符串并说明为何首轮空"
}
],
"开场白全文": "按块序拼好的用户可见开场(主阅读件)",
"开场白全文": "推荐那条:按块序拼好的用户可见开场(主阅读件)",
"开场白候选": [
"推荐那条(与开场白全文相同)",
"可选第二条:同一真相、不同第一拍/角度",
"可选第三条"
],
"开场变量": [
{
"名": "与变量设计真值名一致",
@@ -153,13 +160,14 @@ summary:`开场白与开场变量 · …`(点题场景)。
硬规则:
1. context-fragment.v1;自评契约符合/同真相/可开玩。
2. `开场白全文` 与 `开场变量` 必填;监控栏快照无监控栏则为 `[]`。
2. `开场白全文` 与 `开场变量` 必填;`开场白候选` 1~3 条(含推荐);监控栏快照无监控栏则为 `[]`。
3. 不输出完整 worker 集;不发明新真值名(须来自变量设计,若无变量设计则可极少数字段并标明临时)。
4. summary:`开场白与开场变量 · {brief 缩略}`。
## checklist
```checklist
- [ ] 写出 1~3 条开场候选,推荐条与 `开场白全文` 一致?
- [ ] 开场遵循正文组成块序与叙事口径?
- [ ] 变量与开场同真相?可行动空间清楚?
- [ ] 未剧透隐藏档?未写成第一章全文?
@@ -45,7 +45,8 @@ feeds: narrator
1. 要微调什么?(监控字段、块称呼、要不要建议行动、tone_chrome…)
2. 有没有隐藏段给模型/程序维护变量?
3. 不要谈「再发明一种全新布局」。
3. 定稿时会用前面情节架空一幕灌进壳里,专供你看版式/美化(内容随便,不当正史)。
4. 不要谈「再发明一种全新布局」。
```
## task
@@ -64,7 +65,8 @@ feeds: narrator
4. 监控栏:字段意图(细则可交「设计监控栏」);无 monitor 的壳写「不启用」。
5. 隐藏段(若有):定界与用途。
6. 填「呈现壳.微调」;前端拆分区域必须与壳一致。
7. 自评 + 追问;禁止写 context `order`。
7. **示例**:用前面已定情节架空一幕,按本步壳与可见块灌进 `present.v1` 形状,专供验收版式/美化;示例文可短,游玩正文字数不在本步打磨。
8. 自评 + 追问;禁止写 context `order`。
summary:`正文组成 · 适配 {shell_id} · …`。
```
@@ -73,7 +75,8 @@ summary:`正文组成 · 适配 {shell_id} · …`。
```principles
1. 创作 = 选壳 + 适配微调
不是 UI 创意;布局骨架只读。
不是 UI 创意;布局骨架只读。本步验收重点是「看起来对不对」,不是故事写得好不好。
「按意见修改」:以最新产物为底;用户要求按先后都有效,冲突时以较新的为准。用户说整体可以、或只提美化/行距/显示名 → 不换 shell_id。换壳必须用户点名。
2. 只改微调轴
字段、显示名、开关、tone_chrome、空态;不改分区、不增区域、不自造壳。
@@ -85,6 +88,11 @@ summary:`正文组成 · 适配 {shell_id} · …`。
5. 与转述、变量分工
文风归叙事指南;真值归变量设计;本步只交壳适配契约。
游玩单轮用户主读正文默认 500~2000 字,写进「拼装与终稿.单轮正文篇幅」,供转述遵守。
6. 示例 = 壳与美化预览,非正史
用已定材料随便架空一幕,把各区域/监控/行动坞撑满,让用户看见版式。
禁止把示例当成开场白或游玩 canon;禁止为写好看而新造设定;文笔及格即可,勿打磨成章。
```
## probe
@@ -92,8 +100,8 @@ summary:`正文组成 · 适配 {shell_id} · …`。
```probe
每轮 1~2 点。
优先:选哪个壳;微调哪些字段/显示名;隐藏段要不要。
不追问:新布局、完整 CSS、事件池 schema、文风遣词。
优先:选哪个壳;微调哪些字段/显示名;隐藏段要不要;示例灌数是否让版式看得清。
不追问:新布局、完整 CSS、事件池 schema、文风遣词、示例文笔好坏。
```
## output
@@ -130,7 +138,7 @@ summary:`正文组成 · 适配 {shell_id} · …`。
"可空": true,
"填充方": "叙事转述|程序拼装|监控栏|其它",
"内容形态": "散文|短列表|键值行|其它",
"示例": "可选一句/三行示意"
"示例": "可选:该块一句占位(总预览见正文.示例)"
}
],
"监控栏锚点": {
@@ -172,7 +180,27 @@ summary:`正文组成 · 适配 {shell_id} · …`。
"终稿tag": "通常 输出.用户展示",
"拼装方": "叙事转述一次写出各块|程序按块拼接|混合",
"推荐灌数": "分块壳优先 present.v1;prose 可为纯 Markdown",
"单轮正文篇幅": "默认 500~2000 字(汉字,写入 body);短于 500 仅当用户明确要求极短",
"与裁决包关系": "转述读 settlement 再填各块;隐藏段可含 variable_changes 镜像"
},
"示例": {
"性质": "验收预览|非正史|仅看壳与美化",
"依据": ["前面已定情节/设定要点,一两句即可"],
"用来说明": "这幕只为撑开版式(分区、监控、行动坞、显示名…),内容可糙",
"灌数": {
"schema": "present.v1",
"shell": "与正文.呈现壳.shell_id 一致",
"blocks": {
"monitor": "若该壳有 monitor:短键值;否则省略",
"header": "若有则短字;无则省略",
"body": "架空主读文(短段即可,勿写成章)",
"aside": "若有则短线索/备注;无则省略",
"footer": "若有则可空或一句"
},
"meta": {
"suggested_actions": ["若该壳开建议行动则给 2~3 个占位选项;否则 []"]
}
}
}
},
"自评": {
@@ -191,6 +219,11 @@ summary:`正文组成 · 适配 {shell_id} · …`。
"名": "可实现",
"分数": 8,
"说明": "shell_id 是否在七种壳内;分区与隐藏段是否可交给转述/程序"
},
{
"名": "版式可感",
"分数": 8,
"说明": "示例灌数是否按壳撑开各区,使人一眼能验收美化(不论文笔)"
}
],
"薄弱点": "一句话"
@@ -213,10 +246,12 @@ summary:`正文组成 · 适配 {shell_id} · …`。
1. 合法 JSON;含公共三段外壳。
2. 正文须含 `呈现壳.shell_id`(七种之一)与 `微调`;可见块只挂壳已有区域。
3. `可见块` 至少 1 条(通常含 body);禁止自造区域 id / 第五种壳。
4. 禁止程序 API schema、context `order`、完整 CSS。
5. 自评:可见完备 / 监控克制 / 可实现(=壳适配是否可执行)。
6. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。
7. 兼容旧称产物 tag「设计.回复格式」。
4. 正文须含 `示例`:`性质` 标明非正史;`灌数` 为 `present.v1`,`shell` 与本步 shell_id 一致;只填该壳已有区域。
5. 示例内容可糙:只为验收壳与美化;禁止当开场白/正史;禁止为示例新造设定。
6. 禁止程序 API schema、context `order`、完整 CSS。
7. 自评:可见完备 / 监控克制 / 可实现 / 版式可感。
8. summary:`正文组成 · 适配 {shell_id} · {brief 缩略}`。
9. 兼容旧称产物 tag「设计.回复格式」。
## checklist
@@ -225,18 +260,22 @@ summary:`正文组成 · 适配 {shell_id} · …`。
- [ ] shell_id 在七种壳内?可见块未超出该壳分区?
- [ ] 微调轴已填或明确默认?监控栏未塞死名片字段?
- [ ] 隐藏段(若有)定界清楚?未写 CSS 工程?
- [ ] 有示例灌数(present.v1),壳一致、各区撑开,且标明非正史?
- [ ] 未把示例写成开场白或打磨成长文?
```
## examples
```examples
好:
- 适配 chat_monitor:微调监控字段=好感/今日话题;body 显示名=「短信」。
- 适配 chapter_reader:只开 header+body;aside 关。
- 适配 turn_panel:开 suggested_actions;监控只盯体力/好感。
- 适配 chat_monitor:微调监控字段=好感/今日话题;body 显示名=「短信」;示例灌数把气泡区+监控撑开,文笔随便。
- 适配 chapter_reader:只开 header+body;aside 关;示例给短章头+一段 body。
- 适配 turn_panel:开 suggested_actions;监控只盯体力/好感;示例 meta 带 2~3 个占位行动。
坏:
- 「我们自定义一种左右分栏新界面」。
- 不写 shell_id,只写「要好看的 UI」。
- 监控栏列出姓名、性别等死设定。
- 示例写成第一章正史,或为写好看新造未定设定。
- 示例 shell 与呈现壳不一致,或灌了壳没有的区域。
```
@@ -21,8 +21,8 @@ process:
- 需要门控/分档/程序事件:先「变量设计与更新规则」,再「变量控制上下文」。变量与 Progressive 不是执行单元。
- 挂载归属(非顺序):世界/机制/生成 → 主世界层;叙事指南与故事推进 → 叙事转述(推进兼挂主世界层);美学进常驻。勿为世界观或性格表另开槽。拓扑图谱在固定槽已够用时通常跳过。
- 需要把美学「感觉」落成遣词/笔墨焦点/禁忌与推进时用「叙事指南与故事推进」;事件表 schema 仍用生成规则,真值门控用变量设计。
- 收成链(顺序固定):上游上下文大致齐 →「上下文投影排序」→「细化终稿」→ status: closed。禁止在排序步发明 ref 或重写长文。
- 进游玩前可排「开场白与开场变量」(遵循正文组成/叙事/变量);再由 opening-generator 落库到输出.开场白与运行.初始变量。
- 收成链(顺序固定):上游上下文大致齐 →「上下文投影排序」→「细化终稿」→ 需要开场则排「开场白与开场变量」(〔收口〕:选定后结束创作并保存)。
- 「开场白与开场变量」写出 1~多条开场白;选定后程序落库到输出.开场白与运行.初始变量,不必再调 opening-generator。
- 变量:真值 + Data 映射索引(实例表在具体实例)+ 维护语句(裁决包/隐藏段,非 chance)+ 旁观汇总挂载(变量控制上下文)。
principles:
@@ -39,5 +39,7 @@ prompt_excerpt: |
"meta": { "suggested_actions": ["…"] }
}
monitor 用短键值(芯片),勿写长文。只填该壳已有区域。
用户主读正文(body)默认 500~2000 字(汉字):写成可连读的场面/对话推进,不要几句短信就结束。
禁止在正文里写出 tag 名(「输出.用户展示」)、压缩摘要或过程日志。
禁止写出 do_not_say;禁止编造包中没有的事实。
若裁决含 tone_hint / npc_moves / suggested_actions,须体现在 body 或 meta。
@@ -34,19 +34,19 @@ contextSegments:
label: "## 【用户已选配方】只读引用"
- id: user-demand
tier: dynamic
tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"]
tags: ["用户.需求", "book.brief", "用户.worker答复"]
label: "## 用户表述"
---
# 创作 · 流程编排
你只做一件事:根据用户表述,编排或**增量修订**一份剧本流程(可变 DAG)。
你只做一件事:根据【用户要求】编排或**增量修订**一份剧本流程(可变 DAG)。要求按先后都有效,冲突时以较新的为准。
上下文里会有:
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。
2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次**新建**编入;已完成节点要改用回头修改(mode=revise)
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。
@@ -59,11 +59,14 @@ contextSegments:
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
- 有「编排参数」声明的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params(用户在「下一步」意向后、点同意开始时补齐)。
- 参数不明时:可先产出带空 params 的步骤,或 askUser;禁止把「生成什么 / 调用哪个规则」拖到 design-step 执行中途。
- 用户先认可**下一节点**(含参数),再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
- 〔先验产物〕(生成规则、具体实例):可提前规划「写什么」写入 params;执行时程序注入【规划产物】。规划可空——对象在步内钉。提前规划多条(不同对象/批次)是正确的。**禁止**为钉「生成什么 / 调用哪个规则」而 askUser 拦在确认开干前。
- 其它有「编排参数」的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params。
- 用户先认可**下一节点**,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact(增量补全)。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
同能力**可以**多次出现。必须分清两种:
- **彻底新建**(〔可反复〕如生成规则、具体实例):`mode` 缺省或 `"fresh"`,新 id + 新 params。每一次都是新对象/新批次,**不要**改旧条。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
- **回头修改**:用户要改**已经做过**的节点(漏了、谈浅了、后来发现要改)→ 追加新 id,`"mode": "revise"`,`"revises": "<原步 id>"`。程序会注入既有产物,在旧稿上改。非反复技能**禁止**再排一条 fresh;只能 revise。插在最近已验收步之后、尚未执行的步之前。
## 产出(唯一)
@@ -94,6 +97,13 @@ contextSegments:
"count": "按规则"
},
"depends_on": ["生成规则·重要NPC"]
},
{
"id": "美学纲领与交互范式·改",
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["实现机制"]
}
]
}
@@ -103,25 +113,28 @@ contextSegments:
1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤)
2. `name` = 能力池里的固定中文名(可重复)
3. `id` = 本局步骤唯一键;同 name 多次时必须不同;优先语义化(`生成规则·{对象}`)
3. `id` = 本局步骤唯一键;同 name 多次时必须不同;新建优先语义化(`生成规则·{对象}`);回头修改用 `{原id}·改`
4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name)
5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写
5. `params` = 本步调用参数(对象)。〔先验产物〕:此处是规划产物(可空;已知对象就填,便于区分多条)。其它能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写。回头修改可省略,程序会从 `revises` 那步继承
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
8. 已完成的**非反复**技能(美学纲领、实现机制、舞台骨架、叙事指南等)禁止再排一次执行;只有目录标了〔可反复〕的(生成规则、具体实例等)才可再编入新 id
8. 已完成的**非反复**技能禁止再排一条 `mode=fresh`;要改旧稿只能追加 `mode=revise` + `revises`。〔可反复〕再编入新 id 且 fresh = 彻底新建(新对象/新批次),不是改旧条;改旧条同样用 revise
9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
10. **禁止**把「交互」与「美学」拆成两步
11. **禁止**在本步写能力正文、自由 Worker 列表、表结构
12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order
13. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。
只许问**排布层**的事:下一步排哪个能力、某步的必填 params 取什么值。
12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」→(可开玩时)「开场白与开场变量」。挂谁可早、排第几须晚,勿让中段技能写死 order
13. 「开场白与开场变量」标〔收口〕:排在细化终稿之后作为最后一步;排入后不要再追加其它步,可将 `status` 设为 `closed`。选定开场由程序落库并保存定稿,不必再调 opening-generator
14. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。
只许问**排布层**的事:下一步排哪个能力、普通节点的必填 params 取什么值、是新建还是回头修改哪一步。
〔先验产物〕的「生成什么 / 调用哪个规则」在步内钉,**不要**为此 askUser。
**禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活;
已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」)
14. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
15. `summary`:`流程 · N 步 · open|closed · …`
16. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时,须同步改
15. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
16. `summary`:`流程 · N 步 · open|closed · …`
17. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时(编排修订或步内改规划),须同步改
`params.target`、`params.rule_id`(对应英文 kebab-case,如 `zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。
**禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。
这是**改这条规则的对象**(回头修改或改未验收步),与「再来一条新的生成规则」不是一回事。
## 自检
@@ -130,10 +143,12 @@ contextSegments:
- 是否误排了多次「发明 Worker」?应改为至多一次「游玩拓扑」
- 每步是否按能力 meta 的何时用/不用判断过,而非题材默认?
- 每步 name 都在【能力】里?同名多次是否都有不同 id?
- 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
- 有编排参数的普通步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
- 〔先验产物〕是否误把「生成什么」当成确认开干前必填?规划可空;已知对象才写入 params
- depends_on 是否都指向更靠前的步骤 id?
- 已验收 id 是否都还在?
- 【流程进度】里已完成的非反复技能,是否误当成新规划又排了一次?
- askUser 是否只问了排哪步 / 填哪个参数,而不是体验偏好等正文问题?
- 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步?
- 【流程进度】里已完成的非反复技能,是否误当成 fresh 又排了一次?要改旧稿应 mode=revise
- askUser 是否只问了排哪步 / 填哪个参数 / 新建还是回头修改,而不是体验偏好等正文问题?
- 需要反复补同类内容时,是否用了新 id + 新 params + fresh 追加(彻底新建),而不是改写旧步?
- 用户要改已完成节点时,是否用了 mode=revise + revises,而不是再排一条 fresh?
- 收成前是否把 `status` 设为 `closed`?
@@ -29,24 +29,37 @@ contextSegments:
label: "## 【本步】当前工序 id(对应流程 steps[].id)"
- id: user-demand
tier: dynamic
tags: ["用户.需求", "book.brief", "用户.最新输入", "用户.worker答复", "用户.修订说明"]
tags: ["用户.需求", "book.brief", "用户.worker答复"]
label: "## 用户表述"
---
# 创作 · 执行步骤
你只做 **【本步】** 标明的那一个工序(流程里的一步 id → 能力 name)。
你执行工作流计划里 **【本步】** 那一个工序(步骤 id → 能力 name):按注入的【本步方法】写出该步产物(程序指定的 output tag),交用户验收。其它步骤产物不改;流程只读。
程序会在提示词中追加该工序的方法正文,并注入依赖步骤的已验收产物。
程序会追加该工序的方法正文,并注入依赖步骤的已验收产物。
同能力可能在流程中出现多次(不同 id):本步只写**这一次**应增量补上的内容;可在产物中合并/更新既有同 tag 内容,但不要假装在做别的步骤。
同能力可多次出现(不同 id):
## 纪律
- **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。
- **回头修改**(mode=revise):以【既有产物 · 继承修改】为底稿,改用户点名的部分,保留其余;不从零另起,不再走开场询问。
- **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。
1. 只写本步产物(程序指定的 output tag);不要改其它步骤产物
2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名
3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白
4. 信息不足:context-fragment 产物把题写在「追问」(1~2 点,建议选项 + 示例),顶层 askUser 为 null。仅完全无法产出时才用顶层 askUser
5. `summary`:`{本步能力名} · …`
6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow
7. **本步参数**由编排期写入 steps[].params,程序会注入【本步参数】。按参数执行;禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参
## 执行
1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。
2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。
3. 按方法正文填产物。〔先验产物〕读【规划产物】当起点(可空、可在步内修订);其它节点编排参数见【本步参数】,直接采用。
4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …`
## 缺料时
「不足」只指上面第 1 步认定的事项还不能验收。
- 缺其中一件:在产物「追问」写 1~2 点(建议选项 + 示例),顶层 askUser 为 null。半残 JSON 仍写入 outputs 交验收。仅完全没有可展示正文时才用顶层 askUser。
- 这些事项够验收:`追问.题目` 为 `[]`。方法未点名为必须钉死的键,写「未定」或省略,不发问。
- 用户谈到 `boundary` 里交给其它技能的话题:只抽取能改本步必钉事项的一句写入正文,其余写入产物「开放问题」(无该键则在说明里记一句),本步不就此追问。
- 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。
- 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。
需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。
@@ -3,8 +3,8 @@ id: opening-generator
skill: world-simulator
name: 开局 · 开场白
description: >-
【创作末尾】优先落库「设计.开场白与开场变量」;若无则结合已定规格写出开场白,
并钉与开场同真相的初值。主产物是开场白,不是填表。须用户验收。
【兜底】DAG 未排「开场白与开场变量」时,在创作末尾落库开场白与初值。
若该节点已选定,程序会收口,不必再调本 worker。
version: 3
stage: design-end
inputTags:
@@ -29,13 +29,18 @@ contextSegments:
label: "## 已定世界与故事规格"
- id: user
tier: dynamic
tags: ["用户.需求", "用户.最新输入", "用户.worker答复", "用户.修订说明", "运行.初始变量", "输出.开场白"]
label: "## 用户与开局草稿"
tags: ["用户.需求", "用户.最新输入", "用户.worker答复", "用户.修订说明"]
label: "## 用户表述"
- id: opening-draft
tier: static
tags: ["运行.初始变量", "输出.开场白"]
label: "## 【已有开场】只保留最新稿;若有则在其上改,不要另起"
---
# 开局 · 开场白
创作末尾:规格已定。优先读取 **`设计.开场白与开场变量`**:
创作末尾兜底(DAG **没有**「开场白与开场变量」时才需要本 worker)。
规格已定。优先读取 **`设计.开场白与开场变量`**:
- 若已有且可用:把其中「开场白全文」写入 `输出.开场白`,把「开场变量」写成 `运行.初始变量` / `变量.当前`(表格式);可微调不可另起炉灶。
- 若无:再站在 Worker 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。
+12 -1
View File
@@ -1,5 +1,6 @@
import type { BlackboardItem } from "../types/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import { SLOT_PLAY_TURN_QUEUE, SLOT_PLAY_LAYER_ACTIVE } from "../skills/play-turn.js";
/**
* 判断黑板 tag 是否属于 run 阶段(非实例化确认稿)。
@@ -12,9 +13,14 @@ export function isRunPhaseBlackboardTag(tag: string): boolean {
if (/^book\./.test(tag)) return false;
if (/^情境\./.test(tag)) return false;
if (/^博弈\./.test(tag)) return false;
// 锁定开局:跟规格一起进 instance
if (tag === "输出.开场白" || tag === "运行.初始变量" || tag === "变量.当前") {
return false;
}
// run 层:剥离
// run 层:剥离(含聊天历史,不写进角色卡)
return (
tag === "对话.历史" ||
/^运行\./.test(tag) ||
/^世界\.(当前|裁决)/.test(tag) ||
/^场景\.公开/.test(tag) ||
@@ -35,6 +41,11 @@ export function prepareRuntimeSessionForInstance(session: RuntimeSession): Runti
copy.pendingDecision = undefined;
copy.currentWorkerId = undefined;
copy.waitingReason = undefined;
copy.slots = {
...copy.slots,
[SLOT_PLAY_TURN_QUEUE]: undefined,
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
copy.artifacts = copy.artifacts.filter((a) =>
a.outputTags.every((tag) => !isRunPhaseBlackboardTag(tag)),
);
+62 -45
View File
@@ -1,6 +1,10 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
import { consumeOpenAiToolStream } from "./stream-complete.js";
import {
currentAbortSignal,
throwIfAborted,
} from "./run-abort.js";
export type ToolCallPayload = {
id: string;
@@ -58,6 +62,8 @@ export type CompleteOptions = {
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
/** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal;
};
export type CompleteWithToolsOptions = CompleteOptions & {
@@ -237,6 +243,24 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
return { content: "", toolCalls };
}
async function llmFetch(
url: string,
apiKey: string,
body: unknown,
options?: CompleteOptions,
): Promise<Response> {
throwIfAborted(options?.signal);
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: options?.signal ?? currentAbortSignal(),
});
}
export class OpenAiCompatibleProvider implements LlmProvider {
constructor(private readonly config: LlmConfig) {}
@@ -245,14 +269,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
options?: CompleteOptions,
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(buildRequestBody(this.config, messages, options)),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, options),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -282,16 +304,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, { ...options, stream: true }),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, { ...options, stream: true }),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -319,19 +337,15 @@ export class OpenAiCompatibleProvider implements LlmProvider {
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -362,20 +376,16 @@ export class OpenAiCompatibleProvider implements LlmProvider {
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify(
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
stream: true,
}),
),
});
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
stream: true,
}),
options,
);
if (!response.ok) {
const body = await response.text();
@@ -425,6 +435,7 @@ export class MockLlmProvider implements LlmProvider {
_messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
@@ -448,6 +459,7 @@ export class MockLlmProvider implements LlmProvider {
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
@@ -455,10 +467,12 @@ export class MockLlmProvider implements LlmProvider {
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
for (const ch of response) {
throwIfAborted(options?.signal);
callbacks.onContentDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
@@ -480,6 +494,7 @@ export class MockLlmProvider implements LlmProvider {
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const step = this.nextStep();
if (typeof step === "string") {
return {
@@ -507,9 +522,11 @@ export class MockLlmProvider implements LlmProvider {
_options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const reasoning =
"用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(_options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
+74 -34
View File
@@ -1,4 +1,5 @@
import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js";
import { loadAppSettings } from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import type { PresetPackage } from "../types/preset.js";
import type {
ChatMessage,
@@ -9,9 +10,35 @@ import type {
LlmProvider,
StreamCallbacks,
} from "./client.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "./token-tracker.js";
export type LlmTrackingRef = { current: LlmTrackingContext };
function readActivePreset(): PresetPackage | null {
return resolveActivePreset(loadAppSettings().activePresetId);
}
/**
* 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
* 当前没有选用预设时仍包一层,请求原样转发。
*/
export function wrapLlmForSession(
inner: LlmProvider,
trackingRef?: LlmTrackingRef,
getPreset: () => PresetPackage | null = readActivePreset,
): LlmProvider {
const tracked = trackingRef
? new TokenTrackingProvider(inner, () => trackingRef.current)
: inner;
return new PresetLlmProvider(tracked, getPreset);
}
/**
* 在所有 LLM 请求上合并当前 preset 的生成参数。
* prompt / marker 不在此前置,由 play worker 按扩展 prompt_order 填洞。
*/
export class PresetLlmProvider implements LlmProvider {
constructor(
@@ -19,41 +46,57 @@ export class PresetLlmProvider implements LlmProvider {
private readonly getPreset: () => PresetPackage | null,
) {}
private prepare(
messages: ChatMessage[],
options?: CompleteOptions,
): { messages: ChatMessage[]; options: CompleteOptions | undefined } {
const preset = this.getPreset();
if (!preset) return { messages, options };
// 生成参数全局合并;prompt/marker 只在 play worker 按 prompt_order 填洞,这里不再前置。
return {
messages,
options: {
...options,
generation: options?.generation ?? preset.generation,
},
};
}
async complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.complete(messages, options);
const prepared = this.prepare(messages, options);
return this.inner.complete(prepared.messages, prepared.options);
}
async completeStream(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
if (!this.inner.completeStream) {
const result = await this.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options?.generation ?? preset.generation;
return this.inner.complete(merged, {
...options,
generation,
});
const prepared = this.prepare(messages, options);
return this.inner.completeStream(
prepared.messages,
prepared.options,
callbacks,
);
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!preset) {
return this.inner.completeWithTools(messages, options);
}
const presetMessages = assemblePresetMessages(preset);
const merged = mergeMessages(presetMessages, messages);
const generation = options.generation ?? preset.generation;
return this.inner.completeWithTools(merged, {
const prepared = this.prepare(messages, options);
return this.inner.completeWithTools(prepared.messages, {
...options,
generation,
...prepared.options,
});
}
@@ -62,17 +105,14 @@ export class PresetLlmProvider implements LlmProvider {
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const preset = this.getPreset();
if (!this.inner.completeWithToolsStream) {
return this.completeWithTools(messages, options);
}
const presetMessages = preset ? assemblePresetMessages(preset) : [];
const merged = preset ? mergeMessages(presetMessages, messages) : messages;
const generation = options.generation ?? preset?.generation;
return this.inner.completeWithToolsStream(merged, {
...options,
generation,
}, callbacks);
const prepared = this.prepare(messages, options);
return this.inner.completeWithToolsStream(
prepared.messages,
{ ...options, ...prepared.options },
callbacks,
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
const storage = new AsyncLocalStorage<AbortSignal>();
export function runWithAbortSignal<T>(
signal: AbortSignal,
fn: () => Promise<T>,
): Promise<T> {
return storage.run(signal, fn);
}
export function currentAbortSignal(): AbortSignal | undefined {
return storage.getStore();
}
export function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message = "message" in err ? String(err.message) : "";
return /aborted|AbortError/i.test(message);
}
export function throwIfAborted(signal?: AbortSignal): void {
const active = signal ?? currentAbortSignal();
if (!active?.aborted) return;
const err = new Error("This operation was aborted");
err.name = "AbortError";
throw err;
}
+36 -5
View File
@@ -32,14 +32,21 @@ export type LlmTrackingContext = {
function capturePendingTrace(
ctx: LlmTrackingContext,
messages: Array<{ role: string; content: string }>,
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>,
caller: string | undefined,
model?: string,
generation?: CompleteOptions["generation"],
): void {
ctx.pendingContextTrace = buildContextTrace({
caller: caller ?? "unknown",
messages,
model,
generation,
});
}
@@ -130,7 +137,13 @@ export class TokenTrackingProvider implements LlmProvider {
async () => {
const result = await this.inner.complete(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
capturePendingTrace(
ctx,
messages,
options?.caller,
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -168,7 +181,13 @@ export class TokenTrackingProvider implements LlmProvider {
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
capturePendingTrace(
ctx,
messages,
options?.caller,
result.model,
options?.generation,
);
recordUsage(ctx, options?.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -189,7 +208,13 @@ export class TokenTrackingProvider implements LlmProvider {
async () => {
const result = await this.inner.completeWithTools(messages, options);
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
capturePendingTrace(
ctx,
messages,
options.caller,
result.model,
options.generation,
);
recordUsage(ctx, options.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
@@ -224,7 +249,13 @@ export class TokenTrackingProvider implements LlmProvider {
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
capturePendingTrace(
ctx,
messages,
options.caller,
result.model,
options.generation,
);
recordUsage(ctx, options.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
+3
View File
@@ -77,6 +77,9 @@ const EFFECT_LABEL: Record<string, string> = {
run_programmatic_review: "程序验收",
emit_message: "发消息",
propose_next_creation_step: "提案下一步",
seal_creation_opening: "收口开场白",
run_play_turn: "开游玩回合",
continue_play_turn: "续游玩管线",
};
const TOOL_LABEL: Record<string, string> = {
+10 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import type { LlmProvider } from "../llm/client.js";
import { createLocalLlmDriver, type AgentDriver } from "../runtime/driver.js";
import { normalizeQuestions } from "../skills/question-protocol.js";
import type { BlackboardTagIndex } from "../types/blackboard.js";
import type { MainAgentDecision, RuntimeSession } from "../types/runtime.js";
@@ -45,7 +46,14 @@ ${workerLines}
}
export class MainAgent {
constructor(private readonly llm: LlmProvider) {}
private readonly driver: AgentDriver;
constructor(
private readonly llm: LlmProvider,
driver?: AgentDriver,
) {
this.driver = driver ?? createLocalLlmDriver(llm);
}
/** @deprecated 单次 JSON 决策;请用 runToolLoop */
async decide(context: MainAgentContext): Promise<MainAgentDecision> {
@@ -70,6 +78,7 @@ export class MainAgent {
this.llm,
context,
options.handlers,
this.driver,
);
return decision;
}
+56 -111
View File
@@ -1,12 +1,15 @@
import type { ChatMessage, LlmProvider, ParsedToolCall, StreamCallbacks } from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js";
import type { LlmProvider, ParsedToolCall } from "../llm/client.js";
import { toolCallToDecision, validateLoopToolCall } from "../runtime/tool-registry.js";
import {
createLocalLlmDriver,
type AgentDriver,
type DriverToolCall,
} from "../runtime/driver.js";
import type { MainAgentDecision } from "../types/runtime.js";
import { isMainAgentTerminalTool } from "../types/tools.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
import type { MainAgentContext } from "./main-agent.js";
import { debugLog } from "../log.js";
export type ToolLoopHandlers = {
readBlackboard: (tags: string[]) => Record<string, string>;
@@ -31,8 +34,6 @@ export type ToolLoopResult = {
toolTrace: string[];
};
const MAX_TOOL_LOOP_ITERATIONS = 12;
function buildToolLoopSystemPrompt(
workers: Array<{ id: string; description: string }>,
): string {
@@ -54,35 +55,15 @@ ${workerLines}
选定节点后立即调用 run_worker。`;
}
async function completeToolsPreferStream(
llm: LlmProvider,
messages: ChatMessage[],
callbacks: StreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["completeWithTools"]>>> {
if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
}, callbacks);
}
const result = await llm.completeWithTools(messages, {
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
});
if (result.reasoning) {
callbacks.onReasoningDelta?.(result.reasoning);
}
if (result.content) {
callbacks.onContentDelta?.(result.content);
}
return result;
}
function executeLoopTool(
call: ParsedToolCall,
call: DriverToolCall | ParsedToolCall,
handlers: ToolLoopHandlers,
): string {
const name = validateLoopToolCall(call);
const name = validateLoopToolCall({
id: call.id,
name: call.name,
arguments: call.arguments,
});
const args = JSON.parse(call.arguments || "{}") as Record<string, unknown>;
switch (name) {
@@ -104,102 +85,66 @@ function executeLoopTool(
}
}
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: ParsedToolCall[],
): ChatMessage {
return {
role: "assistant",
content,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void {
const text = reasoning?.trim() ?? "";
if (text) handlers.onThinkingDone?.(text);
}
/**
* 总管 tool loop:在 running 相位内可多轮调用 read_blackboard 等,
* 直到调用终止 tool 并返回 MainAgentDecision。
*
* 循环本身交给 {@link AgentDriver};本函数只解释总管 tool 语义。
*/
export async function runMainAgentToolLoop(
llm: LlmProvider,
context: MainAgentContext,
handlers: ToolLoopHandlers,
driver: AgentDriver = createLocalLlmDriver(llm),
): Promise<ToolLoopResult> {
const messages: ChatMessage[] = [
{
role: "system",
content: buildToolLoopSystemPrompt(context.availableWorkers),
},
{ role: "user", content: buildMainAgentUserPrompt(context) },
];
const toolTrace: string[] = [];
const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
};
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
debugLog("llm", `总管循环 第${iteration}轮`);
const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
emitThinkingDone(handlers, result.reasoning);
const run = await driver.run({
system: buildToolLoopSystemPrompt(context.availableWorkers),
messages: [{ role: "user", content: buildMainAgentUserPrompt(context) }],
tools: MAIN_AGENT_TOOL_DEFINITIONS,
caller: "main_agent",
label: "总管循环",
onThinkingDelta: (delta) => handlers.onThinkingDelta?.(delta),
onThinkingDone: (text) => handlers.onThinkingDone?.(text),
handleStep: (calls) => {
const terminalCalls = calls.filter((tc) => isMainAgentTerminalTool(tc.name));
const loopCalls = calls.filter((tc) => !isMainAgentTerminalTool(tc.name));
if (result.toolCalls.length === 0) {
if (result.content?.trim()) {
const decision = parseMainAgentDecision(result.content);
return { decision, iterations: iteration, toolTrace };
if (terminalCalls.length > 1) {
throw new Error(
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
);
}
throw new Error("Main Agent returned no tool calls and no content");
}
const terminalCalls = result.toolCalls.filter((tc) =>
isMainAgentTerminalTool(tc.name),
);
const loopCalls = result.toolCalls.filter(
(tc) => !isMainAgentTerminalTool(tc.name),
);
if (terminalCalls.length === 1) {
const terminal = terminalCalls[0];
handlers.onToolCall?.(terminal.name, terminal.arguments);
toolTrace.push(`${terminal.name} (terminal)`);
return { kind: "conclude" as const, call: terminal };
}
if (terminalCalls.length > 1) {
throw new Error(
`Main Agent returned multiple terminal tools: ${terminalCalls.map((t) => t.name).join(", ")}`,
);
}
messages.push(
assistantMessageFromToolCalls(result.content, result.toolCalls),
);
if (terminalCalls.length === 1) {
const terminal = terminalCalls[0];
handlers.onToolCall?.(terminal.name, terminal.arguments);
toolTrace.push(`${terminal.name} (terminal)`);
return {
decision: toolCallToDecision(terminal),
iterations: iteration,
toolTrace,
};
}
for (const call of loopCalls) {
const output = executeLoopTool(call, handlers);
handlers.onToolCall?.(call.name, output.slice(0, 200));
toolTrace.push(call.name);
messages.push({
role: "tool",
content: output,
tool_call_id: call.id,
const results = loopCalls.map((call) => {
const output = executeLoopTool(call, handlers);
handlers.onToolCall?.(call.name, output.slice(0, 200));
toolTrace.push(call.name);
return { callId: call.id, content: output };
});
}
return { kind: "continue" as const, results };
},
});
if (run.stop.kind === "text") {
return {
decision: parseMainAgentDecision(run.stop.content),
iterations: run.iterations,
toolTrace,
};
}
throw new Error(
`Main Agent tool loop exceeded ${MAX_TOOL_LOOP_ITERATIONS} iterations`,
);
return {
decision: toolCallToDecision(run.stop.call),
iterations: run.iterations,
toolTrace,
};
}
+7 -3
View File
@@ -1,12 +1,14 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { isStUnfilledMarker } from "./markers.js";
export type MarkerResolver = (identifier: string) => string | null;
const defaultMarkerResolver: MarkerResolver = () => null;
/**
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
* 按 prompt_order 装配 preset 消息。
* 酒馆角色卡 / WI marker 不走 resolver(空洞跳过);自有洞由 resolver 填。
*/
export function assemblePresetMessages(
preset: PresetPackage,
@@ -25,8 +27,10 @@ export function assemblePresetMessages(
if (!entry || !entry.enabled) continue;
let content = entry.content.trim();
if (!content && entry.marker) {
const resolved = resolveMarker(entry.sourceIdentifier);
const markerId = entry.sourceIdentifier || entry.id;
const skipFill = isStUnfilledMarker(markerId);
if (!content && entry.marker && !skipFill) {
const resolved = resolveMarker(markerId);
if (resolved?.trim()) content = resolved.trim();
}
+16 -16
View File
@@ -7,6 +7,7 @@ import type {
PresetPromptOrderItem,
UnsupportedPresetSection,
} from "../types/preset.js";
import { applyExtendedMarkers } from "./markers.js";
type StPrompt = {
identifier?: string;
@@ -186,17 +187,6 @@ export function importSillyTavernPreset(
});
});
const referenced = new Set(promptOrder.map((o) => o.promptId));
const unreferencedCount = prompts.filter(
(p) => !referenced.has(p.id),
).length;
const enabledCount = promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
const generation = readGeneration(raw);
const generationFields = Object.entries(generation)
.filter(([, v]) => v !== undefined)
@@ -211,7 +201,7 @@ export function importSillyTavernPreset(
? (rawInput as { name: string }).name
: "导入的预设");
const preset: PresetPackage = {
const preset: PresetPackage = applyExtendedMarkers({
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
name: presetName,
source: "sillytavern",
@@ -221,13 +211,23 @@ export function importSillyTavernPreset(
unsupported: collectUnsupported(raw),
importedAt: new Date().toISOString(),
raw: rawInput,
};
});
const framedReferenced = new Set(preset.promptOrder.map((o) => o.promptId));
const framedUnreferenced = preset.prompts.filter(
(p) => !framedReferenced.has(p.id),
).length;
const framedEnabled = preset.promptOrder.filter((o) => {
if (!o.enabled) return false;
const entry = preset.prompts.find((p) => p.id === o.promptId);
return entry?.enabled !== false;
}).length;
return {
preset,
promptCount: prompts.length,
enabledCount,
unreferencedCount,
promptCount: preset.prompts.length,
enabledCount: framedEnabled,
unreferencedCount: framedUnreferenced,
missingIdentifiers,
generationFields,
warnings,
+132
View File
@@ -0,0 +1,132 @@
import type {
PresetPackage,
PresetPromptEntry,
PresetPromptOrderItem,
PresetPromptRole,
} from "../types/preset.js";
/** 酒馆角色卡 / WI marker:导入后保留位置,运行时不填我们的正文。 */
export const ST_UNFILLED_MARKERS = new Set([
"worldInfoBefore",
"personaDescription",
"charDescription",
"charPersonality",
"scenario",
"worldInfoAfter",
"dialogueExamples",
]);
export const CHAT_HISTORY_MARKER = "chatHistory";
export const WORLD_BOOK_BEFORE = "worldBookBefore";
export const WORLD_BOOK_AFTER = "worldBookAfter";
export const CURRENT_TURN = "currentTurn";
export const POST_TURN = "postTurn";
/** 导入时插入的自有洞(不含共用的 chatHistory)。 */
export const APP_INSERTED_MARKERS = [
WORLD_BOOK_BEFORE,
WORLD_BOOK_AFTER,
CURRENT_TURN,
POST_TURN,
] as const;
const APP_INSERTED_SET = new Set<string>(APP_INSERTED_MARKERS);
type MarkerDef = {
id: string;
name: string;
role: PresetPromptRole;
};
const MARKER_DEFS: MarkerDef[] = [
{ id: WORLD_BOOK_BEFORE, name: "世界书(历史前)", role: "system" },
{ id: CHAT_HISTORY_MARKER, name: "对话历史", role: "system" },
{ id: WORLD_BOOK_AFTER, name: "世界书(历史后)", role: "system" },
{ id: CURRENT_TURN, name: "本轮对话", role: "user" },
{ id: POST_TURN, name: "本轮之后", role: "system" },
];
function upsertMarkerEntry(
prompts: PresetPromptEntry[],
def: MarkerDef,
): PresetPromptEntry[] {
if (prompts.some((p) => p.id === def.id)) return prompts;
return [
...prompts,
{
id: def.id,
name: def.name,
enabled: true,
role: def.role,
content: "",
marker: true,
sourceIdentifier: def.id,
},
];
}
function reindex(
order: Omit<PresetPromptOrderItem, "orderIndex">[],
): PresetPromptOrderItem[] {
return order.map((item, orderIndex) => ({ ...item, orderIndex }));
}
/**
* 以 chatHistory 为锚点插入四个自有 marker。
* 可重复调用:先摘掉已插入的自有洞,再按规则放回。
*/
export function applyExtendedMarkers(preset: PresetPackage): PresetPackage {
let prompts = [...preset.prompts];
for (const def of MARKER_DEFS) {
prompts = upsertMarkerEntry(prompts, def);
}
const rawOrder = [...preset.promptOrder]
.sort((a, b) => a.orderIndex - b.orderIndex)
.filter((item) => !APP_INSERTED_SET.has(item.promptId));
const histIndex = rawOrder.findIndex(
(item) => item.promptId === CHAT_HISTORY_MARKER,
);
const inserted = (id: string): PresetPromptOrderItem => ({
promptId: id,
enabled: true,
orderIndex: 0,
});
let next: PresetPromptOrderItem[];
if (histIndex < 0) {
next = reindex([
...rawOrder,
inserted(WORLD_BOOK_BEFORE),
inserted(CHAT_HISTORY_MARKER),
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
inserted(POST_TURN),
]);
} else {
const before = rawOrder.slice(0, histIndex);
const history = rawOrder[histIndex]!;
const after = rawOrder.slice(histIndex + 1);
next = reindex([
...before,
inserted(WORLD_BOOK_BEFORE),
history,
inserted(WORLD_BOOK_AFTER),
inserted(CURRENT_TURN),
...after,
inserted(POST_TURN),
]);
}
return {
...preset,
prompts,
promptOrder: next,
};
}
export function isStUnfilledMarker(identifier: string): boolean {
return ST_UNFILLED_MARKERS.has(identifier);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePresetMessages } from "./assembler.js";
import { applyExtendedMarkers } from "./markers.js";
import {
resolveWorldInfoMarker,
type WorldInfoPack,
} from "./world-info-pack.js";
/**
* Play worker:system 契约 + 按扩展 prompt_order 填洞。
* 不要再把同一份 context-order 正文叠成第二包 user。
*/
export function assemblePlayWorkerMessages(params: {
systemPrompt: string;
preset: PresetPackage;
pack: WorldInfoPack;
}): ChatMessage[] {
const framed = applyExtendedMarkers(params.preset);
const filled = assemblePresetMessages(framed, (id) =>
resolveWorldInfoMarker(params.pack, id),
);
return [{ role: "system", content: params.systemPrompt }, ...filled];
}
+130
View File
@@ -0,0 +1,130 @@
import type { ChatMessage } from "../llm/client.js";
import type { PresetPackage } from "../types/preset.js";
import { assemblePlayWorkerMessages } from "./play-frame.js";
import type { WorldInfoPack } from "./world-info-pack.js";
/** 试跑窗口用的短契约,不是 play worker SKILL。 */
export const PROBE_SYSTEM_PROMPT =
"试跑本条预设:根据已拼好的上下文写一轮短回复。产物是对白或叙述,对应本轮用户输入。";
export const DEFAULT_PROBE_CONTEXT = {
loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。",
history: "店员:今晚只剩阁楼那间。\n你:好。",
loreAfter: "时间:深夜。地点:旅馆大厅。",
postTurn: "",
};
export type PresetProbeInput = {
message: string;
loreBefore?: string;
history?: string;
loreAfter?: string;
postTurn?: string;
};
export type PresetProbeMessage = {
role: string;
content: string;
};
export type PresetProbeResult = {
messages: PresetProbeMessage[];
reply: string | null;
reasoning: string | null;
completed: boolean;
skipReason?: string;
error?: string;
};
function optionalEntry(
id: string,
name: string,
content: string,
): WorldInfoPack["worldBookBefore"] {
const text = content.trim();
return text ? [{ id, name, content: text }] : [];
}
export function worldInfoPackFromProbeInput(
input: PresetProbeInput,
): WorldInfoPack {
const loreBefore = input.loreBefore ?? DEFAULT_PROBE_CONTEXT.loreBefore;
const history = input.history ?? DEFAULT_PROBE_CONTEXT.history;
const loreAfter = input.loreAfter ?? DEFAULT_PROBE_CONTEXT.loreAfter;
const postTurn = input.postTurn ?? DEFAULT_PROBE_CONTEXT.postTurn;
return {
worldBookBefore: optionalEntry("probe-before", "试跑设定", loreBefore),
history: history.trim(),
worldBookAfter: optionalEntry("probe-after", "本轮状态", loreAfter),
turn: input.message.trim(),
postTurn: optionalEntry("probe-post", "尾注", postTurn),
};
}
export function assemblePresetProbe(
preset: PresetPackage,
input: PresetProbeInput,
): ChatMessage[] {
return assemblePlayWorkerMessages({
systemPrompt: PROBE_SYSTEM_PROMPT,
preset,
pack: worldInfoPackFromProbeInput(input),
});
}
export function serializeProbeMessages(
messages: ChatMessage[],
): PresetProbeMessage[] {
return messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
}
export async function runPresetProbe(params: {
preset: PresetPackage;
input: PresetProbeInput;
complete?: (
messages: ChatMessage[],
) => Promise<{ content: string; reasoning?: string }>;
}): Promise<PresetProbeResult> {
const message = params.input.message.trim();
if (!message) {
throw new Error("缺少本轮输入");
}
const messages = assemblePresetProbe(params.preset, {
...params.input,
message,
});
const serialized = serializeProbeMessages(messages);
if (!params.complete) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
skipReason: "未请求生成",
};
}
try {
const result = await params.complete(messages);
return {
messages: serialized,
reply: result.content,
reasoning: result.reasoning ?? null,
completed: true,
};
} catch (err) {
return {
messages: serialized,
reply: null,
reasoning: null,
completed: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { BlackboardInputMerge } from "../types/blackboard.js";
import {
isDialogueHistoryRef,
DIALOGUE_HISTORY_TAG,
} from "../skills/dialogue-history.js";
import {
renderContextSegmentBody,
type ContextSegmentDef,
} from "../skills/context-segments.js";
import {
CHAT_HISTORY_MARKER,
CURRENT_TURN,
POST_TURN,
WORLD_BOOK_AFTER,
WORLD_BOOK_BEFORE,
isStUnfilledMarker,
} from "./markers.js";
export const CURRENT_TURN_TAG = "用户.最新输入";
const CURRENT_TURN_ALIASES = new Set([
CURRENT_TURN_TAG,
"currentTurn",
"user.latest",
"用户最新输入",
]);
export function isCurrentTurnRef(ref: string): boolean {
return CURRENT_TURN_ALIASES.has(ref.trim());
}
export type WorldInfoEntry = {
id: string;
name: string;
content: string;
};
export type WorldInfoPack = {
worldBookBefore: WorldInfoEntry[];
history: string;
worldBookAfter: WorldInfoEntry[];
turn: string;
postTurn: WorldInfoEntry[];
};
function emptyPack(): WorldInfoPack {
return {
worldBookBefore: [],
history: "",
worldBookAfter: [],
turn: "",
postTurn: [],
};
}
function entryName(segment: ContextSegmentDef): string {
const raw = (segment.label ?? "").replace(/^#+\s*/, "").trim();
return raw || segment.id;
}
function toEntry(segment: ContextSegmentDef, content: string): WorldInfoEntry {
return {
id: segment.id,
name: entryName(segment),
content: content.trim(),
};
}
export function formatWorldInfoEntries(entries: WorldInfoEntry[]): string {
return entries
.filter((e) => e.content.trim())
.map((e) => {
const title = e.name.trim();
const body = e.content.trim();
return title ? `### ${title}\n\n${body}` : body;
})
.join("\n\n");
}
/**
* 按 contextSegments 相对「对话.历史」「用户.最新输入」切成四袋。
*/
export function worldInfoPackFromSegments(params: {
segments: ContextSegmentDef[];
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge?: BlackboardInputMerge;
}): WorldInfoPack {
const inputMerge = params.inputMerge ?? "latest";
const pack = emptyPack();
let phase: "before" | "after" | "post" = "before";
for (const segment of params.segments) {
const tags = segment.tags ?? [];
const isHist = tags.some((t) => isDialogueHistoryRef(t));
const isTurn = tags.some((t) => isCurrentTurnRef(t));
const body = renderContextSegmentBody(
segment,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (isHist) {
pack.history = body;
phase = "after";
continue;
}
if (isTurn) {
pack.turn = body;
phase = "post";
continue;
}
if (!body) continue;
const entry = toEntry(segment, body);
if (phase === "before") pack.worldBookBefore.push(entry);
else if (phase === "after") pack.worldBookAfter.push(entry);
else pack.postTurn.push(entry);
}
if (!pack.history.trim() && params.inputs[DIALOGUE_HISTORY_TAG]?.trim()) {
pack.history = params.inputs[DIALOGUE_HISTORY_TAG]!.trim();
}
if (!pack.turn.trim() && params.inputs[CURRENT_TURN_TAG]?.trim()) {
pack.turn = params.inputs[CURRENT_TURN_TAG]!.trim();
}
return pack;
}
export function resolveWorldInfoMarker(
pack: WorldInfoPack,
identifier: string,
): string | null {
if (isStUnfilledMarker(identifier)) return null;
switch (identifier) {
case WORLD_BOOK_BEFORE: {
const text = formatWorldInfoEntries(pack.worldBookBefore);
return text || null;
}
case CHAT_HISTORY_MARKER:
return pack.history.trim() || null;
case WORLD_BOOK_AFTER: {
const text = formatWorldInfoEntries(pack.worldBookAfter);
return text || null;
}
case CURRENT_TURN:
return pack.turn.trim() || null;
case POST_TURN: {
const text = formatWorldInfoEntries(pack.postTurn);
return text || null;
}
default:
return null;
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 可替换的 agent 司机:问模型 → 有 tool-call 则交给调用方处理 → 再问,直到不再欠请求。
*
* 相位机、黑板、规格不走这里。换 dsh / 其它 harness 时只换 {@link AgentDriver} 实现。
*
* @module runtime/driver
*/
import type {
ChatMessage,
CompleteWithToolsResult,
LlmProvider,
StreamCallbacks,
ToolDefinition,
} from "../llm/client.js";
import { supportsToolStream } from "../llm/stream-complete.js";
import { debugLog } from "../log.js";
export type DriverToolCall = {
id: string;
name: string;
arguments: string;
};
/** 一步里所有 tool-call 的处理结果:继续把结果写回,或本步结账停循环。 */
export type DriverStepOutcome =
| { kind: "continue"; results: Array<{ callId: string; content: string }> }
| { kind: "conclude"; call: DriverToolCall };
export type DriverRunInput = {
system: string;
messages: ChatMessage[];
tools: ToolDefinition[];
caller?: string;
/** 日志用短名,如「总管循环」 */
label?: string;
maxIterations?: number;
onThinkingDelta?: (delta: string) => void;
onThinkingDone?: (text: string) => void;
handleStep: (
calls: DriverToolCall[],
) => DriverStepOutcome | Promise<DriverStepOutcome>;
};
export type DriverStop =
| { kind: "text"; content: string }
| { kind: "conclude"; call: DriverToolCall };
export type DriverRunResult = {
iterations: number;
stop: DriverStop;
};
/** 司机:直到模型不再欠一次请求。 */
export type AgentDriver = {
run(input: DriverRunInput): Promise<DriverRunResult>;
};
export const DEFAULT_DRIVER_MAX_ITERATIONS = 12;
function assistantMessageFromToolCalls(
content: string | null,
toolCalls: DriverToolCall[],
): ChatMessage {
return {
role: "assistant",
content,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
};
}
function emitThinkingDone(
input: DriverRunInput,
reasoning?: string,
): void {
const text = reasoning?.trim() ?? "";
if (text) input.onThinkingDone?.(text);
}
async function completeToolsPreferStream(
llm: LlmProvider,
messages: ChatMessage[],
tools: ToolDefinition[],
caller: string | undefined,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const options = { tools, caller };
if (supportsToolStream(llm)) {
return llm.completeWithToolsStream!(messages, options, callbacks);
}
const result = await llm.completeWithTools(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
/** 现有 OpenAI 兼容 provider 上的司机,行为对齐旧 tool-loop。 */
export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
return {
async run(input: DriverRunInput): Promise<DriverRunResult> {
const maxIterations = input.maxIterations ?? DEFAULT_DRIVER_MAX_ITERATIONS;
const messages: ChatMessage[] = [
{ role: "system", content: input.system },
...input.messages,
];
const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => input.onThinkingDelta?.(delta),
};
const label = input.label ?? "driver";
for (let iteration = 1; iteration <= maxIterations; iteration++) {
debugLog("llm", `${label} 第${iteration}轮`);
const result = await completeToolsPreferStream(
llm,
messages,
input.tools,
input.caller,
streamCallbacks,
);
emitThinkingDone(input, result.reasoning);
if (result.toolCalls.length === 0) {
const content = result.content?.trim() ?? "";
if (content) {
return { iterations: iteration, stop: { kind: "text", content } };
}
throw new Error("Driver returned no tool calls and no content");
}
const calls: DriverToolCall[] = result.toolCalls.map((tc) => ({
id: tc.id,
name: tc.name,
arguments: tc.arguments,
}));
messages.push(assistantMessageFromToolCalls(result.content, calls));
const outcome = await input.handleStep(calls);
if (outcome.kind === "conclude") {
return {
iterations: iteration,
stop: { kind: "conclude", call: outcome.call },
};
}
for (const item of outcome.results) {
messages.push({
role: "tool",
content: item.content,
tool_call_id: item.callId,
});
}
}
throw new Error(`Driver exceeded ${maxIterations} iterations`);
},
};
}
+11 -19
View File
@@ -1,23 +1,21 @@
import { profileToLlmConfig } from "../config/api-profiles.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
resolveActiveProfile,
} from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import {
createMockMainAgentResponse,
MockLlmProvider,
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import type { PresetPackage } from "../types/preset.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
export type LlmTrackingRef = { current: LlmTrackingContext };
export type { LlmTrackingRef };
function buildInnerLlm(): LlmProvider {
ensureActiveProfileDefault();
@@ -42,22 +40,11 @@ function buildInnerLlm(): LlmProvider {
]);
}
function wrapWithPreset(inner: LlmProvider): LlmProvider {
const settings = loadAppSettings();
const preset = resolveActivePreset(settings.activePresetId);
if (!preset) return inner;
return new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
/** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */
export function createDefaultMainAgentLlm(
trackingRef?: LlmTrackingRef,
): LlmProvider {
const llm = wrapWithPreset(buildInnerLlm());
if (!trackingRef) return llm;
return new TokenTrackingProvider(llm, () => trackingRef.current);
return wrapLlmForSession(buildInnerLlm(), trackingRef);
}
export function hasRealLlmConfig(): boolean {
@@ -76,3 +63,8 @@ export function reloadDefaultMainAgentLlm(
): LlmProvider {
return createDefaultMainAgentLlm(trackingRef);
}
/** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */
export function createLlmForPreset(preset: PresetPackage): LlmProvider {
return wrapLlmForSession(buildInnerLlm(), undefined, () => preset);
}
+2
View File
@@ -158,6 +158,8 @@ export class RuntimeOrchestrator {
await this.runProgrammaticReview(effect.artifactId);
break;
case "invoke_main_agent":
case "propose_next_creation_step":
case "seal_creation_opening":
break;
}
}
+138 -34
View File
@@ -30,6 +30,12 @@ import {
isModuleOpeningQuestions,
normalizeQuestions,
} from "../skills/question-protocol.js";
import {
CREATION_SEALED_WAITING_MESSAGE,
SLOT_CREATION_SEALED_BY_OPENING,
isOpeningSealArtifact,
} from "../skills/opening-seal.js";
import { inferLifecycleStage } from "../skills/worker-declaration.js";
import type { QuestionItem } from "../types/questions.js";
function nowIso(): string {
@@ -81,7 +87,45 @@ function mergeSlotText(
const trimmed = text.trim();
if (!trimmed) return;
const prev = String(slots[key] ?? "").trim();
slots[key] = prev ? `${prev}\n\n${trimmed}` : trimmed;
if (!prev) {
slots[key] = trimmed;
return;
}
if (
prev === trimmed ||
prev.includes(`\n\n${trimmed}`) ||
prev.startsWith(`${trimmed}\n\n`)
) {
return;
}
slots[key] = `${prev}\n\n${trimmed}`;
}
/** 同一步内多次「按意见修改」叠意见;较新的写在后面 */
function accumulateRevisionInstruction(prev: unknown, next: string): string {
const trimmed = next.trim();
const old = typeof prev === "string" ? prev.trim() : "";
if (!old || old === trimmed) return trimmed;
if (old.includes(trimmed)) return old;
return `${old}\n\n——\n\n${trimmed}`;
}
/** 验收后把本步意见并入「用户.需求」按时间保留,再清掉本步槽位以免下一步误进修订 */
function archiveStepNotesIntoDemand(slots: Record<string, unknown>): void {
const reply =
typeof slots["用户.worker答复"] === "string"
? slots["用户.worker答复"].trim()
: "";
const note =
typeof slots["用户.修订说明"] === "string"
? slots["用户.修订说明"].trim()
: "";
if (reply) mergeSlotText(slots, "用户.需求", reply);
if (note) mergeSlotText(slots, "用户.需求", note);
delete slots["用户.修订说明"];
delete slots.revisionInstruction;
delete slots.revisionTargetArtifactId;
delete slots["用户.worker答复"];
}
/** 追加事件到 history 并 touch */
@@ -102,6 +146,30 @@ function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined });
}
/** 游玩连跑下一执行单元;创作则交总管 */
function afterPlayOrMainAgent(
session: RuntimeSession,
next: RuntimeSession,
): ApplyEventResult {
const cleared = {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
};
if (inferLifecycleStage(session) === "play") {
return {
session: touch(running(cleared)),
effects: [{ type: "continue_play_turn" }],
};
}
return {
session: touch(running(cleared)),
effects: [{ type: "invoke_main_agent" }],
};
}
function findArtifact(
session: RuntimeSession,
artifactId: string,
@@ -150,11 +218,14 @@ function beginRevisionRerun(
artifact: ArtifactRecord,
instruction: string,
): ApplyEventResult {
const trimmed = instruction.trim();
const trimmed = accumulateRevisionInstruction(
session.slots["用户.修订说明"] ?? session.slots.revisionInstruction,
instruction,
);
const slots: Record<string, unknown> = {
...session.slots,
"用户.修订说明": trimmed,
"用户.最新输入": trimmed,
"用户.最新输入": instruction.trim(),
revisionInstruction: trimmed,
// 本次重跑的底稿;重跑写不出新版时只许退回这一份
revisionTargetArtifactId: artifact.id,
@@ -319,7 +390,10 @@ function handleWorkerCompleted(
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const mode = session.acceptanceMode ?? "user_confirmed";
const mode =
inferLifecycleStage(session) === "play"
? "no_confirmation"
: (session.acceptanceMode ?? "user_confirmed");
let next = appendHistory(session, event);
if (next.slots.revisionTargetArtifactId) {
@@ -346,16 +420,13 @@ function handleWorkerCompleted(
if (mode === "no_confirmation") {
next = updateArtifact(next, artifact.id, { status: "accepted" });
return {
session: touch({
...running(next),
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
}),
effects: [{ type: "invoke_main_agent" }],
};
return afterPlayOrMainAgent(session, {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
});
}
// programmatic_review
@@ -587,9 +658,8 @@ export function applyEvent(
if (session.waitingReason?.kind === "worker_questions") {
slots["用户.worker答复"] = text;
if (text) slots["用户.最新输入"] = text;
if (demandKey && text) {
mergeSlotText(slots, demandKey, text);
}
// 开场/追问答复只进「用户.worker答复」,不追加进「用户.需求」
// (否则后续步骤的用户表述会把各步答复叠成一份长转录,修订意见被淹没)
} else if (session.waitingReason?.kind === "input" && text) {
const key = demandKey || "用户.需求";
applyUserTextToSlots(slots, text, key, {
@@ -615,6 +685,28 @@ export function applyEvent(
};
}
if (inferLifecycleStage(session) === "play") {
return {
session: touch(running({ ...next, resumeContext: undefined })),
effects: [{ type: "run_play_turn" }],
};
}
if (session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
return {
session: waiting(touch({ ...next, resumeContext: undefined }), {
kind: "input",
message: CREATION_SEALED_WAITING_MESSAGE,
}),
effects: [
{
type: "emit_message",
message: "创作已收口并保存。请切换到「游玩」开始。",
},
],
};
}
// 常规定稿用户输入 → 等总管下一步
return {
session: touch(running({ ...next, resumeContext: undefined })),
@@ -817,7 +909,10 @@ export function applyEvent(
case "worker_completed": {
const result = handleWorkerCompleted(session, event);
const mode = session.acceptanceMode ?? "user_confirmed";
const mode =
inferLifecycleStage(session) === "play"
? "no_confirmation"
: (session.acceptanceMode ?? "user_confirmed");
if (mode === "user_confirmed" && result.session.pendingArtifactId) {
const sidecar = asOptionalSidecarQuestions(event.payload.questions);
const assessment =
@@ -908,6 +1003,7 @@ export function applyEvent(
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const slots: Record<string, unknown> = { ...session.slots };
archiveStepNotesIntoDemand(slots);
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集");
if (isFinalSet) {
@@ -924,24 +1020,29 @@ export function applyEvent(
pendingDecision: undefined,
currentWorkerId: undefined,
};
// 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑)
const gateNextIntent =
!isFinalSet &&
(artifact.workerId === "design-step" ||
artifact.workerId === "design-flow");
if (gateNextIntent) {
// 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。
if (isOpeningSealArtifact(artifact)) {
return {
session: waiting(cleared, {
kind: "next_intent",
afterWorkerId: artifact.workerId,
kind: "input",
message: CREATION_SEALED_WAITING_MESSAGE,
}),
effects: [
{
type: "emit_message",
message:
"这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。",
},
],
effects: [{ type: "seal_creation_opening" }],
};
}
if (inferLifecycleStage(session) === "play") {
return afterPlayOrMainAgent(session, cleared);
}
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
const proposeNext =
artifact.workerId === "design-step" ||
artifact.workerId === "design-flow";
if (proposeNext) {
return {
session: touch(running(cleared)),
effects: [{ type: "propose_next_creation_step" }],
};
}
return {
@@ -1029,7 +1130,10 @@ export function applyEvent(
session: touch(
running({ ...next, pendingArtifactId: undefined }),
),
effects: [{ type: "invoke_main_agent" }],
effects:
inferLifecycleStage(session) === "play"
? [{ type: "continue_play_turn" }]
: [{ type: "invoke_main_agent" }],
};
}
+239 -26
View File
@@ -62,6 +62,7 @@ import {
CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog,
isReviseStep,
mergeCreationFlowPreservingAccepted,
nextPendingStep,
parseCreationFlow,
@@ -71,10 +72,12 @@ import {
resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag,
missingRequiredStepParams,
stepUnitId,
stringifyCreationFlow,
stringifyModuleOpeningState,
type CreationFlowStepParams,
type ModuleCatalogEntry,
type ModuleParamSpec,
} from "../skills/creation-flow.js";
import {
@@ -86,6 +89,22 @@ import {
SLOT_ASKED_QUESTIONS,
} from "../skills/question-protocol.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import {
closeCreationFlowRaw,
CREATION_SEALED_WAITING_MESSAGE,
mergeOpeningTablePatch,
OPENING_CURRENT_VARS_TAG,
OPENING_INITIAL_VARS_TAG,
OPENING_OUTPUT_TAG,
OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload,
SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js";
import {
readPlayTurnQueue,
withPlayTurnQueue,
} from "../skills/play-turn.js";
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
import {
executeChance,
@@ -272,6 +291,26 @@ export class PhaseRuntime {
this.lastWorkerRunSnapshot = null;
}
/**
* 执行中卡住:有进行中的 worker 则按开跑快照重跑;否则重跑总管。
*/
async retryStuckRun(): Promise<RuntimeSession> {
const workerId = this.session.currentWorkerId;
const snap = this.lastWorkerRunSnapshot;
if (workerId && snap && snap.workerId === workerId) {
this.restoreFromCheckpoint(
snap.runtimeSession,
snap.blackboardItems,
);
return this.rerunWorker(workerId);
}
if (this.session.phase === "running" && !this.session.waitingReason) {
await this.runMainAgent();
return this.session;
}
throw new Error("当前没有可重试的执行");
}
/** 重 roll 指定 worker(刷新 Skill 回复) */
async rerunWorker(workerId: string): Promise<RuntimeSession> {
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
@@ -575,15 +614,38 @@ export class PhaseRuntime {
}
/** 用户接受 pendingArtifact;创作单位验收 vs 终稿验收分流 */
async acceptArtifact(artifactId?: string): Promise<RuntimeSession> {
async acceptArtifact(
artifactId?: string,
opts?: { openingIndex?: number },
): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId;
if (!id) throw new Error("当前没有待验收的产物");
const artifact = this.session.artifacts.find((a) => a.id === id);
if (artifact && opts?.openingIndex != null && Number.isFinite(opts.openingIndex)) {
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_OPENING_SELECTED_INDEX]: Math.trunc(opts.openingIndex),
},
};
}
const unitAccept = artifact ? isDesignUnitArtifact(artifact) : false;
// 细化终稿写「设计.worker集」,不是单位草稿,但仍须记入已验收步骤,
// 否则编排器会当成没做完再跑一遍。须在 dispatch 之前写入。
const shouldRecordStep = Boolean(
artifact && (unitAccept || artifact.workerId === "design-step"),
);
if (artifact && shouldRecordStep) {
const slots = { ...this.session.slots };
this.recordCreationUnitAccepted(slots, artifact);
this.session = { ...this.session, slots };
}
await this.dispatch({ type: "user_accepted_artifact", payload: { artifactId: id } });
this.persistInstanceWorkerDeclaration();
if (artifact) {
const unitAccept = isDesignUnitArtifact(artifact);
const result = compressAfterWorkerAccept({
blackboard: this.blackboard,
workerId: artifact.workerId,
@@ -598,8 +660,7 @@ export class PhaseRuntime {
lastCompressedWorkerId: artifact.workerId,
};
if (unitAccept) {
this.recordCreationUnitAccepted(slots, artifact);
if (shouldRecordStep) {
const unitId = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
this.onMessage(
`[创作单位已验收] ${unitId || artifact.workerId}:${
@@ -608,7 +669,8 @@ export class PhaseRuntime {
);
slots[SLOT_CREATION_CURRENT_UNIT] = undefined;
slots[SLOT_CREATION_UNIT_ANCHOR_AT] = undefined;
} else {
}
if (!unitAccept) {
this.onMessage(
`[上下文已压缩] ${artifact.workerId}:保留 ${result.finals.map((f) => f.tag).join("、") || "(无终产物)"};归档 ${result.archivedTags.length} 个过程 tag。下一阶段以定稿为准。`,
);
@@ -814,6 +876,15 @@ export class PhaseRuntime {
case "propose_next_creation_step":
await this.proposeNextCreationStep();
break;
case "seal_creation_opening":
this.sealCreationOpening();
break;
case "run_play_turn":
await this.startPlayTurn();
break;
case "continue_play_turn":
await this.continuePlayTurn();
break;
}
}
}
@@ -822,6 +893,10 @@ export class PhaseRuntime {
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
*/
private async proposeNextCreationStep(): Promise<void> {
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
return;
}
this.syncSlotsToBlackboard(this.session);
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
@@ -853,6 +928,9 @@ export class PhaseRuntime {
const active = this.getActiveSkill();
let paramSpecs: ModuleParamSpec[] = [];
let declaration = "";
let auto = false;
let kind: ModuleCatalogEntry["kind"];
let pendingModule: ModuleCatalogEntry | null = null;
if (active?.name) {
try {
const skill = await loadSkill(active.name);
@@ -861,6 +939,9 @@ export class PhaseRuntime {
const mod = catalog?.modules.find((m) => m.name === pending.name);
paramSpecs = mod?.params ?? [];
declaration = mod?.declaration?.trim() || "";
auto = mod?.auto === true;
kind = mod?.kind;
pendingModule = mod ?? null;
}
} catch {
/* catalog optional */
@@ -870,13 +951,7 @@ export class PhaseRuntime {
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
const params = pending.params ? { ...pending.params } : {};
const paramsMissing = paramSpecs
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
const intent =
typeof this.session.slots["用户.下一步意向"] === "string"
@@ -890,8 +965,9 @@ export class PhaseRuntime {
: paramsMissing.length
? `;待钉参数:${paramsMissing.join("、")}`
: "";
const revise = isReviseStep(pending);
const reason = [
`下一步:${pending.name}`,
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`,
declaration ? `—— ${declaration}` : "",
paramHint,
intent ? `(你的意向:${intent})` : "",
@@ -912,6 +988,9 @@ export class PhaseRuntime {
hint: p.hint,
})),
intent: intent || undefined,
...(kind ? { kind } : {}),
...(revise ? { mode: "revise" as const } : {}),
...(pending.revises ? { revises: pending.revises } : {}),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
@@ -930,7 +1009,8 @@ export class PhaseRuntime {
action: "run_worker",
workerId: "design-step",
reason,
requiresApproval: true,
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
requiresApproval: !(auto && paramsMissing.length === 0),
});
await this.dispatch({
type: "main_agent_decision_created",
@@ -938,6 +1018,81 @@ export class PhaseRuntime {
});
}
/**
* 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。
*/
private sealCreationOpening(): void {
const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX];
const selectedIndex =
typeof selectedRaw === "number"
? selectedRaw
: typeof selectedRaw === "string" && selectedRaw.trim()
? Number(selectedRaw)
: 0;
const fragmentRaw =
this.blackboard.getContentByTag(OPENING_SETUP_ARTIFACT_TAG) ?? "";
const payload = parseOpeningSealPayload(fragmentRaw, selectedIndex);
const existingOpening = this.blackboard.getContentByTag(OPENING_OUTPUT_TAG)?.trim();
const openingText = payload?.selectedText?.trim() || existingOpening || "";
if (openingText) {
this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup");
}
if (payload?.variables.length) {
const source = "worker:opening-setup";
const initialPatch = mergeOpeningTablePatch(
this.blackboard.getContentByTag(OPENING_INITIAL_VARS_TAG),
payload.variables,
source,
);
if (initialPatch) {
this.writeWorkerTagContent(OPENING_INITIAL_VARS_TAG, initialPatch, "opening-setup");
}
const currentPatch = mergeOpeningTablePatch(
this.blackboard.getContentByTag(OPENING_CURRENT_VARS_TAG),
payload.variables,
source,
);
if (currentPatch) {
this.writeWorkerTagContent(OPENING_CURRENT_VARS_TAG, currentPatch, "opening-setup");
}
}
const closed = closeCreationFlowRaw(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (closed) {
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content: closed,
source: "runtime",
});
}
const n = payload?.candidates.length ?? (openingText ? 1 : 0);
const which =
n > 1 && payload
? `第 ${payload.selectedIndex + 1}/${n} 条`
: n
? "开场白"
: "开场";
this.onMessage(
`[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`,
);
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_SEALED_BY_OPENING]: true,
[CREATION_FLOW_TAG]: closed ?? this.session.slots[CREATION_FLOW_TAG],
[OPENING_OUTPUT_TAG]: openingText || this.session.slots[OPENING_OUTPUT_TAG],
},
};
}
private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
@@ -947,6 +1102,7 @@ export class PhaseRuntime {
if (!raw?.trim()) return;
let snap: {
stepId?: string;
kind?: string;
paramSpecs?: Array<{ key: string; required?: boolean }>;
};
try {
@@ -967,13 +1123,16 @@ export class PhaseRuntime {
content,
source: "user",
});
const paramsMissing = (snap.paramSpecs ?? [])
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const paramsMissing =
snap.kind === "prior-artifact"
? []
: (snap.paramSpecs ?? [])
.filter((p) => p.required)
.map((p) => p.key)
.filter((key) => {
const v = params[key];
return v == null || (typeof v === "string" && !v.trim());
});
const snapshot = {
...snap,
params,
@@ -1122,6 +1281,7 @@ export class PhaseRuntime {
this.onMessage(
`[Worker] ${workerId} 执行中…${resolved.source === "declaration" ? "(声明驱动)" : ""}`,
);
debugLog("step", `调用模型 ${workerId}`);
this.onWorkerStreamStart?.(workerId);
try {
@@ -1131,10 +1291,7 @@ export class PhaseRuntime {
slots,
blackboard: this.blackboard,
llm: workerLlm,
declared:
resolved.source === "declaration"
? { worker: resolved.worker, promptBody: resolved.promptBody }
: undefined,
declared: { worker: resolved.worker, promptBody: resolved.promptBody },
stream: {
onThinkingDelta: (delta) =>
this.onWorkerThinkingDelta?.(workerId, delta),
@@ -1193,8 +1350,13 @@ export class PhaseRuntime {
});
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
const hasPlayVisible = Object.keys(result.outputs).some(
(t) => t === "输出.用户展示" || t === "输出.开场白",
);
this.onMessage(
`[Worker] ${workerId} 已完成\n\n${result.preview}${result.preview.length >= 4000 ? "\n\n…" : ""}`,
`[Worker] ${workerId} 已完成\n\n${result.preview}${
!hasPlayVisible && result.preview.length >= 4000 ? "\n\n…" : ""
}`,
);
await this.dispatch({
@@ -1907,8 +2069,59 @@ export class PhaseRuntime {
);
}
/** 游玩:按 playWorkerIds 开一轮 */
private async startPlayTurn(): Promise<void> {
this.syncSlotsToBlackboard(this.session);
const ids = this.getInstanceWorkerDeclaration().playWorkerIds;
if (!ids.length) {
this.onMessage(
"[游玩] 运行规格里没有可上场的执行单元,无法推进回合。",
);
this.waitForPlayInput();
return;
}
const [first, ...rest] = ids;
this.session = {
...this.session,
slots: withPlayTurnQueue(this.session.slots, rest),
};
await this.processEffects([{ type: "run_worker", workerId: first }]);
}
/** 游玩:跑管线下一个;队空则等用户 */
private async continuePlayTurn(): Promise<void> {
if (this.session.phase === "waiting_user") return;
const queue = readPlayTurnQueue(this.session.slots);
if (!queue.length) {
this.waitForPlayInput();
return;
}
const [next, ...rest] = queue;
this.session = {
...this.session,
slots: withPlayTurnQueue(this.session.slots, rest),
};
await this.processEffects([{ type: "run_worker", workerId: next }]);
}
private waitForPlayInput(): void {
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "input" },
currentWorkerId: undefined,
pendingArtifactId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
slots: withPlayTurnQueue(this.session.slots, undefined),
updatedAt: new Date().toISOString(),
};
}
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
if (inferLifecycleStage(this.session) === "play") return;
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
await this.runMainAgent();
}
+9 -2
View File
@@ -399,7 +399,10 @@ export function buildFocus(
return {
actorType: "user",
actorLabel: "你",
action: "补充说明",
action:
reason.message && /创作已收口/.test(reason.message)
? "创作已收口"
: "补充说明",
detail: reason.message?.slice(0, 120),
};
}
@@ -412,6 +415,7 @@ export function buildFocus(
try {
return JSON.parse(String(session.slots["创作.待确认步骤"])) as {
name?: string;
mode?: string;
};
} catch {
return null;
@@ -423,7 +427,9 @@ export function buildFocus(
actorId: "orchestrator",
actorLabel: "编排",
action: proposed?.name
? `确认开始 · ${proposed.name}`
? proposed.mode === "revise"
? `回头修改 · ${proposed.name}`
: `确认开始 · ${proposed.name}`
: `建议调用 ${displayWorkerLabel(worker)}`,
detail: session.pendingDecision?.reason,
};
@@ -467,6 +473,7 @@ export function buildFocus(
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
outputTags: art?.outputTags,
});
const optionalQs = reason.questions?.length
? `;另有 ${reason.questions.length} 道可选追问`
+5 -2
View File
@@ -146,10 +146,13 @@ export type ReviewComposerCopy = {
*/
export function reviewComposerCopy(
workerId: string | undefined | null,
opts?: { hasQuestions?: boolean },
opts?: { hasQuestions?: boolean; outputTags?: readonly string[] },
): ReviewComposerCopy {
const hasQuestions = Boolean(opts?.hasQuestions);
const id = (workerId ?? "").trim();
const openingReview =
id === "opening-generator" ||
(opts?.outputTags ?? []).includes("设计.开场白与开场变量");
if (id === "design-flow") {
return {
@@ -169,7 +172,7 @@ export function reviewComposerCopy(
};
}
if (id === "opening-generator") {
if (id === "opening-generator" || openingReview) {
return {
chip: "开场",
kicker: "待选定",
+540 -78
View File
@@ -10,6 +10,22 @@ import {
import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js";
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
import {
CREATION_SEALED_WAITING_MESSAGE,
INSTANCE_OPENING_SNAPSHOT_LABEL,
OPENING_OUTPUT_TAG,
SLOT_CREATION_SEALED_BY_OPENING,
} from "../skills/opening-seal.js";
import {
PLAY_WORKING_SNAPSHOT_ID,
PLAY_WORKING_SNAPSHOT_LABEL,
SLOT_PLAY_LAYER_ACTIVE,
isPlayLayerActive,
} from "../skills/play-turn.js";
import {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
} from "../skills/dialogue-history.js";
import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js";
import { createSession } from "../runtime/phase-machine.js";
import {
@@ -18,6 +34,7 @@ import {
reloadDefaultMainAgentLlm,
type LlmTrackingRef,
} from "../runtime/llm-factory.js";
import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js";
import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
@@ -179,6 +196,8 @@ export type SessionView = {
/** @deprecated 用 skillCatalog */
pipeline: StageStep[];
lifecycleStage: LifecycleStage;
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
playLayerActive: boolean;
playReady: boolean;
skillCatalog: SkillCatalogEntry[];
toolTrace: ToolTraceEntry[];
@@ -271,7 +290,13 @@ export type ProposedNextStepView = {
required: boolean;
hint?: string;
}>;
/** 节点特性:prior-artifact = 先验产物(不在确认开干时钉「写什么」) */
kind?: string;
/** 用户在「下一步想写什么」里写的意向(与步骤 mode 不同) */
intent?: string;
/** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise";
revises?: string;
};
export type ReviewArtifactView = {
@@ -279,6 +304,7 @@ export type ReviewArtifactView = {
workerId: string;
summary?: string;
body: string;
outputTags?: string[];
/** 产出该产物的 worker_output 消息(验收态下 feed 会隐藏该消息) */
sourceMessageId?: string;
/** 与 sourceMessage 同源的全量 LLM 上下文,供待验收卡片右键查看 */
@@ -301,6 +327,12 @@ type ManagedSession = {
recipeOptions?: RecipeCatalogEntry[];
/** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */
runAbort?: AbortController;
/** 递增世代:后一次请求作废前一次 */
runGen?: number;
/** 本轮开始时的消息条数,取消时裁掉执行中产生的调度句 */
runMessageCutoff?: number;
};
export class SessionManager {
@@ -327,7 +359,11 @@ export class SessionManager {
if (stage === "play" && !canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
s.runtime.setLifecycleStage(stage);
if (stage === "play") {
this.enterPlayLayer(s, { resume: true });
} else {
this.enterDesignLayer(s);
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
@@ -582,10 +618,10 @@ export class SessionManager {
return toRunSnapshotMeta(snapshot);
}
/** 列出某 Book 的全部存档 */
/** 列出某 Book 的全部存档(不含内部「当前游玩」工作副本) */
listGameSnapshots(bookId: string): RunSnapshotMeta[] {
if (!getBook(bookId)) throw new Error("Book 不存在");
return listRunSnapshots(bookId);
return listRunSnapshots(bookId).filter((s) => s.id !== PLAY_WORKING_SNAPSHOT_ID);
}
/** 仅游玩 run 存档(UI 抽屉用) */
@@ -604,7 +640,7 @@ export class SessionManager {
if (!canEnterPlay(session)) {
throw new Error("须先验收 Worker 集,才能保存游玩存档");
}
if (inferLifecycleStage(session) !== "play") {
if (inferLifecycleStage(session) !== "play" || !isPlayLayerActive(session.slots)) {
throw new Error("请先切换到「游玩」再保存游玩存档");
}
return this.saveGameSnapshot(sessionId, label, "run", note);
@@ -618,16 +654,97 @@ export class SessionManager {
if (!canEnterPlay(session)) {
throw new Error("须先验收 Worker 集,才能开始游玩");
}
this.archivePlayWorking(s.bookId);
this.enterPlayLayer(s, { resume: false });
this.persist(s);
return this.toView(sessionId);
}
let runtimeSession = structuredClone(session);
/** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */
private enterPlayLayer(
s: ManagedSession,
opts: { resume: boolean },
): void {
const session = s.runtime.getSession();
if (!canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
if (
opts.resume &&
isPlayLayerActive(session.slots) &&
inferLifecycleStage(session) === "play"
) {
this.sanitizePlayHitl(s);
return;
}
if (s.bookId) this.ensureInstanceSnapshot(s);
if (opts.resume && s.bookId) {
const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID);
if (working?.kind === "run") {
this.restoreSnapshotInPlace(s, working, "play");
return;
}
}
this.beginNewPlayFromInstance(s);
if (s.bookId) this.savePlayWorking(s);
}
/** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */
private enterDesignLayer(s: ManagedSession): void {
const session = s.runtime.getSession();
if (
inferLifecycleStage(session) === "design" &&
!isPlayLayerActive(session.slots)
) {
s.runtime.setLifecycleStage("design");
return;
}
if (s.bookId && isPlayLayerActive(session.slots)) {
this.savePlayWorking(s);
}
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
if (instance) {
this.restoreSnapshotInPlace(s, instance, "design");
return;
}
let runtimeSession = structuredClone(s.runtime.getSession());
let blackboardItems = s.runtime.getBlackboard().exportItems();
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
runtimeSession.id = session.id;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
runtimeSession.phase = "waiting_user";
runtimeSession.waitingReason = { kind: "input" };
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
}
private beginNewPlayFromInstance(s: ManagedSession): void {
const keepId = s.runtime.getSession().id;
let runtimeSession: RuntimeSession;
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
if (instance) {
runtimeSession = structuredClone(instance.runtimeSession);
blackboardItems = structuredClone(instance.blackboardItems);
} else {
runtimeSession = structuredClone(s.runtime.getSession());
blackboardItems = s.runtime.getBlackboard().exportItems();
}
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "play",
[SLOT_PLAY_LAYER_ACTIVE]: true,
startupCompleted: true,
};
runtimeSession.phase = "waiting_user";
@@ -636,10 +753,166 @@ export class SessionManager {
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
runtimeSession.resumeContext = undefined;
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
this.persist(s);
return this.toView(sessionId);
const opening =
s.runtime.getBlackboard().getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
if (opening) {
s.runtime.getBlackboard().write({
tag: DIALOGUE_HISTORY_TAG,
content: appendDialogueHistoryTurn("", { role: "助手", text: opening }),
source: "runtime",
});
s.messages = [this.playOpeningMessage(opening)];
} else {
s.messages = [];
}
s.branchState = createMessageBranchState();
}
private restoreSnapshotInPlace(
s: ManagedSession,
snapshot: RunSnapshot,
layer: LifecycleStage,
): void {
const keepId = s.runtime.getSession().id;
let runtimeSession = structuredClone(snapshot.runtimeSession);
let blackboardItems = structuredClone(snapshot.blackboardItems);
if (snapshot.kind === "instance" || layer === "design") {
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: layer,
[SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined,
startupCompleted: true,
};
if (layer === "play") {
runtimeSession.phase = runtimeSession.phase === "done"
? "waiting_user"
: runtimeSession.phase;
if (!runtimeSession.waitingReason) {
runtimeSession.waitingReason = { kind: "input" };
}
} else {
const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]);
runtimeSession.phase = "waiting_user";
runtimeSession.waitingReason = {
kind: "input",
...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}),
};
runtimeSession.pendingArtifactId = undefined;
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
}
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
s.messages = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
s.branchState = createMessageBranchState();
if (layer === "play") this.sanitizePlayHitl(s);
}
/** 游玩层不保留创作 HITL(验收 / 确认下一步 / 修订) */
private sanitizePlayHitl(s: ManagedSession): void {
const session = s.runtime.getSession();
const play =
isPlayLayerActive(session.slots) || inferLifecycleStage(session) === "play";
if (!play) return;
const kind = session.waitingReason?.kind;
if (
kind !== "review_artifact" &&
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent"
) {
return;
}
session.phase = "waiting_user";
session.waitingReason = { kind: "input" };
session.pendingArtifactId = undefined;
session.pendingDecision = undefined;
session.currentWorkerId = undefined;
session.resumeContext = undefined;
session.updatedAt = new Date().toISOString();
}
private savePlayWorking(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
const runtimeSession = structuredClone(s.runtime.getSession());
const snapshot: RunSnapshot = {
version: 1,
id: PLAY_WORKING_SNAPSHOT_ID,
bookId: s.bookId,
label: PLAY_WORKING_SNAPSHOT_LABEL,
kind: "run",
orchestratorId:
sessionSkillPackId(runtimeSession) ??
(book ? bookSkillPackId(book) : "") ??
"",
runtimeSession,
blackboardItems: s.runtime.getBlackboard().exportItems(),
messages: s.messages.map((m) => ({ ...m })),
createdAt: new Date().toISOString(),
note: "切换创作/游玩时自动保存的当前聊天",
};
saveRunSnapshotFile(snapshot);
}
private archivePlayWorking(bookId: string): void {
const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID);
if (!working) return;
if (!working.messages.some((m) => m.role === "user")) return;
const stamped: RunSnapshot = {
...working,
id: randomUUID(),
label: `游玩 ${new Date().toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
createdAt: new Date().toISOString(),
note: "新建游玩前自动保存",
};
saveRunSnapshotFile(stamped);
}
private ensureInstanceSnapshot(s: ManagedSession): void {
if (!s.bookId) return;
if (this.latestInstanceSnapshot(s.bookId)) return;
if (!canEnterPlay(s.runtime.getSession())) return;
try {
this.saveGameSnapshot(
s.runtime.getSession().id,
INSTANCE_OPENING_SNAPSHOT_LABEL,
"instance",
"进入游玩前自动保存定稿",
);
} catch (err) {
console.warn("[会话] 进入游玩前保存定稿失败", err);
}
}
private latestInstanceSnapshot(bookId: string): RunSnapshot | null {
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
if (!meta) return null;
return loadRunSnapshotFile(bookId, meta.id);
}
private playOpeningMessage(text: string): ChatMessage {
return {
id: randomUUID(),
role: "system",
text,
createdAt: new Date().toISOString(),
kind: "system_info",
actor: "narrator",
title: "开场白",
body: text,
};
}
/** 从存档读档:替换当前作品进度,可继续创作 */
@@ -665,10 +938,16 @@ export class SessionManager {
runtimeSession,
blackboardItems,
}));
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
} else {
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "play",
[SLOT_PLAY_LAYER_ACTIVE]: true,
};
}
@@ -725,6 +1004,16 @@ export class SessionManager {
opts?: { answers?: QuestionAnswer[] },
): Promise<SessionView> {
const s = this.require(id);
this.sanitizePlayHitl(s);
const waitingBefore = s.runtime.getSession().waitingReason;
if (
inferLifecycleStage(s.runtime.getSession()) === "play" &&
!isPlayLayerActive(s.runtime.getSession().slots) &&
waitingBefore?.kind === "input"
) {
this.enterPlayLayer(s, { resume: true });
if (s.bookId) this.persist(s);
}
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
@@ -764,35 +1053,36 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
try {
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") {
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
if (sidecarAnswers.length && reason.questions?.length) {
const qaText = formatQuestionAnswersForAi(
normalizeQuestions(reason.questions),
sidecarAnswers,
trimmed || undefined,
);
await s.runtime.resolveSidecarQuestions(qaText);
const owned = await this.runExclusive(id, s, async () => {
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") {
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
if (sidecarAnswers.length && reason.questions?.length) {
const qaText = formatQuestionAnswersForAi(
normalizeQuestions(reason.questions),
sidecarAnswers,
trimmed || undefined,
);
await s.runtime.resolveSidecarQuestions(qaText);
}
if (trimmed || sidecarAnswers.length) {
await s.runtime.rejectArtifact(
trimmed || "按追问作答更新产物",
);
}
} else {
await s.runtime.submitInput(trimmed || text);
}
if (trimmed || sidecarAnswers.length) {
await s.runtime.rejectArtifact(
trimmed || "按追问作答更新产物",
);
}
} else {
await s.runtime.submitInput(trimmed || text);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 发送消息失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally {
this.clearAgentThinking(id);
this.syncCreationDialogue(s);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
@@ -838,22 +1128,23 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else {
await s.runtime.submitInput(aiText);
}
const owned = await this.runExclusive(id, s, async () => {
if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else {
await s.runtime.submitInput(aiText);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 回答追问失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally {
this.clearAgentThinking(id);
this.syncCreationDialogue(s);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
@@ -872,7 +1163,10 @@ export class SessionManager {
this.clearAgentThinking(id);
s.messages.push(this.msg("user", "跳过可选追问"));
try {
await s.runtime.resolveSidecarQuestions();
const owned = await this.runExclusive(id, s, () =>
s.runtime.resolveSidecarQuestions(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -918,7 +1212,10 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
await s.runtime.submitInput(trimmed);
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitInput(trimmed),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -974,7 +1271,10 @@ export class SessionManager {
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
await s.runtime.rerunWorker(workerId);
const owned = await this.runExclusive(id, s, () =>
s.runtime.rerunWorker(workerId),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
@@ -997,6 +1297,32 @@ export class SessionManager {
return this.toView(id);
}
/** 中止当前生成并重跑卡住的 worker / 总管 */
async abortAndRetry(id: string): Promise<SessionView> {
const s = this.require(id);
const session = s.runtime.getSession();
if (session.phase !== "running" || session.waitingReason) {
throw new Error("当前没有正在执行的任务");
}
const cutoff = s.runMessageCutoff ?? s.messages.length;
s.messages = s.messages.slice(0, cutoff);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, async () => {
await s.runtime.retryStuckRun();
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止并重试失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 删除消息及其后的对话 */
async deleteMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id);
@@ -1053,7 +1379,10 @@ export class SessionManager {
): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.approve(stepParams);
const owned = await this.runExclusive(id, s, () =>
s.runtime.approve(stepParams),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认开干失败", detail);
@@ -1065,7 +1394,10 @@ export class SessionManager {
async confirmIntake(id: string): Promise<SessionView> {
const s = this.require(id);
try {
await s.runtime.confirmIntake();
const owned = await this.runExclusive(id, s, () =>
s.runtime.confirmIntake(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认需求失败", detail);
@@ -1075,7 +1407,10 @@ export class SessionManager {
return this.toView(id);
}
async accept(id: string): Promise<SessionView> {
async accept(
id: string,
opts?: { openingIndex?: number },
): Promise<SessionView> {
const s = this.require(id);
const pendingId = s.runtime.getSession().pendingArtifactId;
const waiting = s.runtime.getSession().waitingReason;
@@ -1085,7 +1420,21 @@ export class SessionManager {
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
: undefined;
const stageBefore = inferLifecycleStage(s.runtime.getSession());
await s.runtime.acceptArtifact();
const sealedBefore = Boolean(
s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING],
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.acceptArtifact(undefined, opts),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 接受产物失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
this.persist(s);
return this.toView(id);
}
// 接受产物 = 不再用追问完善;询问模块随 waitingReason 清除而收起
if (hadSidecarQuestions) {
s.messages.push(
@@ -1101,6 +1450,8 @@ export class SessionManager {
}
}
this.persist(s);
this.maybeSaveOpeningInstance(s, sealedBefore);
this.persist(s);
return this.toView(id);
}
@@ -1109,13 +1460,16 @@ export class SessionManager {
const s = this.require(id);
const waiting = s.runtime.getSession().waitingReason;
try {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 打回失败", detail);
@@ -1127,26 +1481,42 @@ export class SessionManager {
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
async finish(id: string): Promise<SessionView> {
const s = this.require(id);
await s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
@@ -1308,6 +1678,42 @@ export class SessionManager {
}
}
/** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */
private maybeSaveOpeningInstance(
s: ManagedSession,
sealedBefore: boolean,
): void {
if (sealedBefore) return;
if (!s.bookId) return;
const session = s.runtime.getSession();
if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
if (!canEnterPlay(session)) {
s.messages.push(
this.msg(
"system",
"[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。",
),
);
return;
}
try {
const meta = this.saveGameSnapshot(
session.id,
INSTANCE_OPENING_SNAPSHOT_LABEL,
"instance",
"选定开场后自动保存",
);
s.messages.push(
this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`),
);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(
this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`),
);
}
}
private persist(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
@@ -1423,7 +1829,7 @@ export class SessionManager {
private buildLiveStreamView(sessionId: string): LiveStreamView | undefined {
const worker = this.workerLive.get(sessionId);
if (worker && (worker.thinking || worker.output)) {
if (worker) {
return {
actor: "worker",
actorId: worker.workerId,
@@ -1449,6 +1855,42 @@ export class SessionManager {
return s;
}
private beginRun(s: ManagedSession, sessionId: string): number {
s.runAbort?.abort();
s.runAbort = new AbortController();
s.runGen = (s.runGen ?? 0) + 1;
s.runMessageCutoff = s.messages.length;
this.clearAgentThinking(sessionId);
return s.runGen;
}
private isCurrentRun(s: ManagedSession, gen: number): boolean {
return s.runGen === gen;
}
/** true = 本轮仍有效;false = 已被停止并重试取代 */
private async runExclusive(
id: string,
s: ManagedSession,
fn: () => Promise<unknown>,
): Promise<boolean> {
const gen = this.beginRun(s, id);
try {
await runWithAbortSignal(s.runAbort!.signal, fn);
return this.isCurrentRun(s, gen);
} catch (err) {
if (isAbortError(err) && !this.isCurrentRun(s, gen)) {
return false;
}
throw err;
} finally {
if (this.isCurrentRun(s, gen)) {
this.clearAgentThinking(id);
s.runAbort = undefined;
}
}
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
@@ -1595,6 +2037,7 @@ export class SessionManager {
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
const s = this.require(id);
this.sanitizePlayHitl(s);
const session = s.runtime.getSession();
const reason = session.waitingReason;
const book = s.bookId ? getBook(s.bookId) : null;
@@ -1667,7 +2110,15 @@ export class SessionManager {
: "描述你想写什么…",
});
} else if (reason?.kind === "input") {
if (reason.questions?.length) {
const playNow =
inferLifecycleStage(session) === "play" || isPlayLayerActive(session.slots);
if (playNow) {
actions.push({
type: "send_message",
label: "发送",
placeholder: "说你要做什么…",
});
} else if (reason.questions?.length) {
const qs = normalizeQuestions(reason.questions);
const hasAssessment = Boolean(reason.message?.trim());
hints.push(
@@ -1732,11 +2183,17 @@ export class SessionManager {
);
if (proposed) {
hints.push(
`确认开始「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: ""
}`,
proposed.mode === "revise"
? `回头修改「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: "(继承既有产物继续改,不是从零再生成)"
}`
: `确认开始「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: ""
}`,
);
actions.push({
type: "approve",
@@ -1749,10 +2206,10 @@ export class SessionManager {
actions.push({
type: "send_message",
label: "说明意见",
placeholder: "不同意可说明要改成什么…",
placeholder: "改排、回头改某步,或说明不要这一步…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。");
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
actions.push({
type: "send_message",
label: "继续",
@@ -1859,6 +2316,7 @@ export class SessionManager {
actions,
pipeline: buildPipeline(session),
lifecycleStage,
playLayerActive: isPlayLayerActive(session.slots),
playReady: canEnterPlay(session),
skillCatalog,
toolTrace: buildToolTrace(messages),
@@ -2246,7 +2704,10 @@ function readProposedNextStep(
}))
.filter((p) => p.key)
: [],
kind: typeof doc.kind === "string" ? doc.kind : undefined,
intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined,
};
} catch {
return undefined;
@@ -2416,6 +2877,7 @@ function buildReviewArtifactView(
workerId: art.workerId,
summary,
body,
outputTags: art.outputTags,
sourceMessageId: outputMsg?.id,
contextTrace: outputMsg?.contextTrace,
workerSet,
+51
View File
@@ -22,12 +22,17 @@ import {
patchPresetEntries,
type PresetEntryPatch,
} from "../preset/entries.js";
import { runPresetProbe } from "../preset/probe.js";
import {
deletePreset,
getPreset,
importAndSavePreset,
listPresets,
} from "../preset/store.js";
import {
createLlmForPreset,
hasRealLlmConfig,
} from "../runtime/llm-factory.js";
import { sessionManager } from "./session-manager.js";
async function readBody(req: IncomingMessage): Promise<string> {
@@ -226,6 +231,52 @@ export async function handleSettingsApi(
return true;
}
const presetProbeMatch = pathname.match(/^\/api\/presets\/([^/]+)\/probe$/);
if (presetProbeMatch && req.method === "POST") {
const id = decodeURIComponent(presetProbeMatch[1]);
const preset = getPreset(id);
if (!preset) {
json(res, 404, { error: "预设不存在" });
return true;
}
const body = JSON.parse(await readBody(req)) as {
message?: string;
loreBefore?: string;
history?: string;
loreAfter?: string;
postTurn?: string;
complete?: boolean;
};
try {
const wantComplete = body.complete !== false;
const canComplete = wantComplete && hasRealLlmConfig();
const llm = canComplete ? createLlmForPreset(preset) : null;
const result = await runPresetProbe({
preset,
input: {
message: body.message ?? "",
loreBefore: body.loreBefore,
history: body.history,
loreAfter: body.loreAfter,
postTurn: body.postTurn,
},
complete: llm
? (messages) =>
llm.complete(messages, { caller: "preset-probe" })
: undefined,
});
if (!llm && wantComplete) {
result.skipReason = "未配置 API Key,只返回拼装后的上下文";
}
json(res, 200, result);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : String(err),
});
}
return true;
}
const presetEntriesMatch = pathname.match(
/^\/api\/presets\/([^/]+)\/entries$/,
);
+1 -1
View File
@@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog(
id: "declare-ready",
stage: "design",
label: "实例就绪",
purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。",
purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。",
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
});
+18 -2
View File
@@ -444,9 +444,11 @@ const server = createServer(async (req, res) => {
const body = JSON.parse(await readBody(req)) as {
action?: string;
stepParams?: Record<string, unknown>;
openingIndex?: number;
};
let view;
switch (body.action) {
try {
switch (body.action) {
case "approve":
view = await sessionManager.approve(
sessionId,
@@ -459,7 +461,12 @@ const server = createServer(async (req, res) => {
view = await sessionManager.confirmIntake(sessionId);
break;
case "accept":
view = await sessionManager.accept(sessionId);
view = await sessionManager.accept(
sessionId,
typeof body.openingIndex === "number"
? { openingIndex: body.openingIndex }
: undefined,
);
break;
case "skip_questions":
view = await sessionManager.skipQuestions(sessionId);
@@ -473,10 +480,19 @@ const server = createServer(async (req, res) => {
case "finish":
view = await sessionManager.finish(sessionId);
break;
case "retry_run":
view = await sessionManager.abortAndRetry(sessionId);
break;
default:
json(res, 400, { error: "未知 action" });
return;
}
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "操作失败",
});
return;
}
json(res, 200, view);
return;
}
+24 -5
View File
@@ -163,12 +163,22 @@ export function projectFragmentContent(
const frag = parseContextFragment(raw);
const mode = projection.trim().toLowerCase();
if (!frag) {
if (mode === "summary" && raw.length > 800) return `${raw.slice(0, 800)}…`;
if ((mode === "summary" || mode === "brief" || mode === "index") && raw.length > 800) {
return `${raw.slice(0, 800)}…`;
}
return raw;
}
if (mode === "summary" || mode === "brief") {
return frag.brief || raw;
}
if (mode === "index") {
const lines: string[] = [];
if (frag.技能) lines.push(`技能:${frag.技能}`);
if (frag.brief) lines.push(frag.brief);
if (frag.mount.length) lines.push(`挂载:${frag.mount.join("、")}`);
if (frag.稳变) lines.push(`稳变:${frag.稳变}`);
return lines.join("\n") || frag.brief || raw;
}
if (mode === "fields") {
if (frag.正文 && typeof frag.正文 === "object" && !Array.isArray(frag.正文)) {
const keys = Object.keys(frag.正文 as object);
@@ -237,9 +247,11 @@ export function contextFragmentToView(raw: unknown): ContextFragmentView {
const probe = rawDoc?.追问;
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const row = probe as Record<string, unknown>;
const lines: string[] = [];
if (typeof row.导语 === "string" && row.导语.trim()) lines.push(row.导语.trim());
const qs = Array.isArray(row.题目) ? row.题目 : [];
const lines: string[] = [];
if (qs.length && typeof row.导语 === "string" && row.导语.trim()) {
lines.push(row.导语.trim());
}
qs.forEach((q, i) => {
if (!q || typeof q !== "object") return;
const r = q as Record<string, unknown>;
@@ -282,7 +294,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
const looksFragment =
row.schema === CONTEXT_FRAGMENT_SCHEMA ||
(typeof row.brief === "string" && ("正文" in row || "body" in row));
typeof row.技能 === "string" ||
(typeof row.brief === "string" &&
("正文" in row || "body" in row || "追问" in row));
if (!looksFragment) return { questions: [], assessment: "" };
const assessmentParts: string[] = [];
@@ -358,7 +372,12 @@ export function extractFragmentAskSidecar(raw: unknown): {
}
const questions = normalizeQuestions(rawQs);
const assessment = [lead, ...assessmentParts].filter(Boolean).join("\n");
const assessment = [
questions.length ? lead : "",
...assessmentParts,
]
.filter(Boolean)
.join("\n");
return { questions, assessment };
}
+60 -6
View File
@@ -6,9 +6,14 @@
import type { ContextSegmentDef } from "./context-segments.js";
import { extractJsonObjectText } from "./worker-set-parse.js";
import {
DEFAULT_ON_DEMAND_REFS,
DEFAULT_PLAY_SLOT_REFS,
defaultPlaySlots,
parsePlaySlots,
playSlotIdForRef,
PLAY_SLOT_META,
PLAY_SLOTS_WITHOUT_DIALOGUE_HISTORY,
resolvePlayAgentId,
type PlaySlotsConfig,
} from "./play-slots.js";
import {
@@ -87,20 +92,53 @@ function parseInsert(raw: unknown): ContextOrderInsert | undefined {
function parseSlot(raw: unknown): ContextOrderSlot | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const ref = asString(row.ref) ?? asString(row.id);
if (!ref) return undefined;
const rawRef = asString(row.ref) ?? asString(row.id);
if (!rawRef) return undefined;
const slotId = resolvePlayAgentId(rawRef);
const ref = slotId
? slotId === "chance"
? DEFAULT_ON_DEMAND_REFS.chance
: DEFAULT_PLAY_SLOT_REFS[slotId]
: rawRef;
const insertsRaw = Array.isArray(row.inserts) ? row.inserts : [];
const inserts = insertsRaw
.map(parseInsert)
.filter((x): x is ContextOrderInsert => Boolean(x))
.sort((a, b) => a.order - b.order);
const label =
asString(row.label) ??
asString(row.name) ??
(slotId && slotId !== "chance" ? PLAY_SLOT_META[slotId].label : undefined);
return {
ref,
label: asString(row.label) ?? asString(row.name),
label,
inserts,
};
}
function agentEnabledFlag(row: Record<string, unknown>): boolean {
if (typeof row.enabled === "boolean") return row.enabled;
if (row.enabled === "false" || row.enabled === 0) return false;
return true;
}
function playSlotsFromAgents(
rows: Record<string, unknown>[],
): PlaySlotsConfig {
const base = defaultPlaySlots();
for (const row of rows) {
const rawId = asString(row.id) ?? asString(row.ref) ?? asString(row.label);
if (!rawId) continue;
const slotId = resolvePlayAgentId(rawId);
if (!slotId) continue;
const on = agentEnabledFlag(row);
if (slotId === "chance") base.chance = on;
else if (slotId === "auditor") base.auditor = on;
else base[slotId] = on;
}
return base;
}
export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
@@ -118,16 +156,32 @@ export function parseContextOrder(raw: unknown): ContextOrderDoc | undefined {
}
if (!row) return undefined;
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const slots = slotsRaw
const agentsRaw = Array.isArray(row.agents) ? row.agents : [];
const agentRows = agentsRaw.filter(
(a): a is Record<string, unknown> =>
Boolean(a) && typeof a === "object" && !Array.isArray(a),
);
const fromAgents = agentRows
.filter(agentEnabledFlag)
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slotsRaw = Array.isArray(row.slots) ? row.slots : [];
const fromSlots = slotsRaw
.map(parseSlot)
.filter((x): x is ContextOrderSlot => Boolean(x));
const slots = fromAgents.length ? fromAgents : fromSlots;
if (!slots.length && row.schema !== CONTEXT_ORDER_SCHEMA) return undefined;
const play_slots = fromAgents.length
? playSlotsFromAgents(agentRows)
: parsePlaySlots(row.play_slots ?? row.playSlots);
return {
schema: CONTEXT_ORDER_SCHEMA,
brief: asString(row.brief),
play_slots: parsePlaySlots(row.play_slots ?? row.playSlots),
play_slots,
slots,
};
}
+159 -39
View File
@@ -89,17 +89,40 @@ function readTagContent(
return items[items.length - 1]?.content?.trim() ?? "";
}
/** 单段投影正文(不含 label)。供世界书 marker 填洞复用。 */
export function renderContextSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
return formatSegmentBody(
segment,
inputs,
blackboard,
inputMerge,
skipExact,
skipTags,
);
}
function formatSegmentBody(
segment: ContextSegmentDef,
inputs: Record<string, string>,
blackboard: Blackboard,
inputMerge: BlackboardInputMerge,
skipExact?: Set<string>,
skipTags?: Set<string>,
): string {
if (segment.inline?.trim()) {
return applyProjection(segment.inline.trim(), segment.projection);
}
const parts: string[] = [];
const seen = new Set<string>();
for (const tag of segment.tags) {
if (skipTags?.has(tag)) continue;
let content: string;
if (isDialogueHistoryRef(tag)) {
content = resolveDialogueHistory(inputs, blackboard, inputMerge);
@@ -115,8 +138,22 @@ function formatSegmentBody(
content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection);
}
if (!content.trim()) continue;
parts.push(content.trim());
const trimmed = content.trim();
if (!trimmed) continue;
if (skipExact?.has(trimmed)) continue;
if (seen.has(trimmed)) continue;
if (
parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
)
) {
continue;
}
seen.add(trimmed);
parts.push(trimmed);
}
return parts.join("\n\n");
}
@@ -141,6 +178,61 @@ function applyProjection(content: string, projection?: string): string {
return projectFragmentContent(content, projection);
}
const USER_REQUIREMENT_TAGS = new Set([
"用户.需求",
"book.brief",
"用户.最新输入",
"用户.worker答复",
"用户.修订说明",
]);
const CREATION_REVIEW_WORKERS = new Set([
"design-step",
"design-flow",
"opening-generator",
]);
function alreadyCovered(parts: string[], next: string): boolean {
const trimmed = next.trim();
if (!trimmed) return true;
return parts.some(
(p) =>
p === trimmed ||
p.includes(`\n\n${trimmed}`) ||
p.startsWith(`${trimmed}\n\n`),
);
}
function pushUnique(parts: string[], next: string): void {
const trimmed = next.trim();
if (!trimmed || alreadyCovered(parts, trimmed)) return;
parts.push(trimmed);
}
/** 创作步:用户要求按时间排;冲突只看较新,旧条未点名推翻则仍有效。 */
function collectUserRequirementBlock(params: {
inputs: Record<string, string>;
blackboard: Blackboard;
inputMerge: BlackboardInputMerge;
}): string {
const { inputs, blackboard, inputMerge } = params;
const ordered = [
readTagContent("用户.需求", inputs, blackboard, inputMerge),
readTagContent("book.brief", inputs, blackboard, inputMerge),
readTagContent("用户.worker答复", inputs, blackboard, inputMerge),
readTagContent("用户.最新输入", inputs, blackboard, inputMerge),
readTagContent("用户.修订说明", inputs, blackboard, inputMerge),
];
const parts: string[] = [];
for (const item of ordered) pushUnique(parts, item);
if (!parts.length) return "";
return [
"## 【用户要求】按先后排列。冲突时以较新的为准;未被较新要求改写的旧条仍有效。",
"",
...parts,
].join("\n\n");
}
/**
* 按 contextSegments 拼装 user 侧上下文。
* 无 segments 时回退为 JSON inputs(兼容旧 skill)。
@@ -178,10 +270,49 @@ export function assembleWorkerContext(params: {
);
}
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const inheritExisting = segments.some((s) => s.id === "inherit-existing");
const splitUserReq = CREATION_REVIEW_WORKERS.has(params.workerId);
const draftBlocks: string[] = [];
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
// 只有本步真有底稿(或回头修改)才算修订;上一步残留的修订说明仍作为用户要求保留
const inheritMode = inheritExisting || (Boolean(revisionNote) && draftBlocks.length > 0);
const skipTags = new Set<string>();
if (splitUserReq) {
for (const tag of USER_REQUIREMENT_TAGS) skipTags.add(tag);
}
const blocks: string[] = [];
const render = (seg: ContextSegmentDef) => {
const body = formatSegmentBody(seg, params.inputs, params.blackboard, inputMerge);
if (inheritMode && seg.id === "module-opening") return;
const body = formatSegmentBody(
seg,
params.inputs,
params.blackboard,
inputMerge,
undefined,
skipTags,
);
if (!body) return;
if (seg.label) {
blocks.push(`${seg.label}\n\n${body}`);
@@ -193,6 +324,15 @@ export function assembleWorkerContext(params: {
// 严格按 segments 数组顺序(投影排序表顺序);不再按 static/dynamic 重排
for (const seg of segments) render(seg);
if (splitUserReq) {
const reqBlock = collectUserRequirementBlock({
inputs: params.inputs,
blackboard: params.blackboard,
inputMerge,
});
if (reqBlock) blocks.push(reqBlock);
}
// 定稿摘要:若未在 segments 中声明,仍附在末尾
const brief = params.inputs[CONTEXT_BRIEF_TAG]?.trim();
const briefInSegments = segments.some((s) => s.tags.includes(CONTEXT_BRIEF_TAG));
@@ -200,39 +340,17 @@ export function assembleWorkerContext(params: {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
}
// 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
const revisionNote = readTagContent(
"用户.修订说明",
params.inputs,
params.blackboard,
inputMerge,
).trim();
const draftBlocks: string[] = [];
if (revisionNote) {
for (const tag of params.outputTags) {
const alreadyInSegments = segments.some((s) => s.tags.includes(tag));
if (alreadyInSegments) continue;
const draft = readTagContent(
tag,
params.inputs,
params.blackboard,
inputMerge,
).trim();
if (!draft) continue;
draftBlocks.push(`### \`${tag}\`\n\n${draft}`);
}
if (draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**。按「用户.修订说明」以及「用户.worker答复」中的追问作答(选项与补充)在其上修改,并写回同名 outputTags。",
"节点方法(task / principles / probe / output)仍须遵守。保留未要求改动的结构与结论;禁止无故整份重写。",
"",
...draftBlocks,
].join("\n"),
);
}
if (inheritMode && draftBlocks.length) {
blocks.push(
[
"## 【待改底稿】",
"",
"下列为**当前已有产物**(只保留这一份最新稿)。在其上落实【用户要求】里较新的条目;与旧要求冲突时以较新的为准,未点名改写的旧条仍须保留在产物里。",
"节点方法(task / principles / probe / output)仍须遵守。禁止无视较新要求原样交回。",
"",
...draftBlocks,
].join("\n"),
);
}
const taskLines = [
@@ -243,10 +361,12 @@ export function assembleWorkerContext(params: {
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
];
if (revisionNote) {
if (inheritMode) {
taskLines.push(
"**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答(选项与补充意见)。",
"不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。",
inheritExisting
? "**回头修改**:接着上方【既有产物 · 继承修改】(以及【待改底稿】若有)继续改,写回同一产物。"
: "**修订模式**:以【待改底稿】为底,落实【用户要求】;冲突时以较新的为准。",
"不要从零另起一份。标为「只读 / 已定稿」的分区仍不可改。",
);
} else {
taskLines.push(
+316 -63
View File
@@ -53,9 +53,41 @@ const DEFAULT_SKILLS_ROOT = path.resolve(
"../../skills",
);
/** 步骤调用参数(编排期钉死;执行期只读) */
/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕:编排器规划的产物内容(可空),执行时注入为【规划产物】。 */
export type CreationFlowStepParams = Record<string, unknown>;
/**
* 节点特性。缺省 = 普通执行步。后续会加更多 kind。
* prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。
*/
export const MODULE_NODE_KINDS = ["prior-artifact"] as const;
export type ModuleNodeKind = (typeof MODULE_NODE_KINDS)[number];
export const MODULE_NODE_KIND_FLAGS: Record<ModuleNodeKind, string> = {
"prior-artifact": "〔先验产物〕",
};
export function parseModuleNodeKind(raw: unknown): ModuleNodeKind | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim();
return (MODULE_NODE_KINDS as readonly string[]).includes(key)
? (key as ModuleNodeKind)
: undefined;
}
export function isPriorArtifactModule(
module: { kind?: ModuleNodeKind } | null | undefined,
): boolean {
return module?.kind === "prior-artifact";
}
/**
* 本步怎么跑:
* - fresh(缺省)= 从零产出。〔可反复〕再编入新 id 必须用这个——彻底新建,不改旧条。
* - revise = 回头修改:继承 `revises` 所指那步的既有产物继续改。
*/
export type CreationFlowStepMode = "fresh" | "revise";
export type CreationFlowStep = {
/**
* 本局步骤唯一 id(验收与 depends_on 用这个)。
@@ -67,10 +99,15 @@ export type CreationFlowStep = {
/** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */
depends_on: string[];
/**
* 本步调用参数。有「编排参数」声明的能力必须在进 design-step 前钉齐必填项。
* 例:生成规则 → target;具体实例 → rule_id。
* 本步调用参数。
* 普通节点:有必填声明时须在进执行前钉齐。
* 〔先验产物〕:编排器规划的产物内容(可空);执行注入【规划产物】,步内可修订。
*/
params?: CreationFlowStepParams;
/** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */
mode?: CreationFlowStepMode;
/** mode=revise 时:被改的既有步骤 id(须更前、同 name) */
revises?: string;
};
export type CreationFlowStatus = "open" | "closed";
@@ -111,14 +148,29 @@ export type ModuleCatalogEntry = {
* 程序不硬拦;给编排与校验提示。
*/
repeatable?: boolean;
/**
* 可选:创作终节点。选定后程序收口并保存,其后不要再追加步骤。
*/
closer?: boolean;
/**
* 可选:程序步。不抛默认问题、不经「同意并开始」,提案后直接执行;
* 产物仍走验收。适合投影排序这类只排序、几乎不问用户的收成步。
*/
auto?: boolean;
/**
* 可选:节点特性。缺省为普通执行步。
* prior-artifact = 先验产物,须先定写什么;params 是规划产物而非拦执行的必填项。
*/
kind?: ModuleNodeKind;
/**
* 可选:默认问题(开场白)。优先用 prompt.md 的 ```opening 块;
* catalog 写了则作覆盖。程序发出,不经 LLM。
*/
opening?: string;
/**
* 可选:编排期步骤参数声明。
* 有 required 项时,steps[].params 必须在进执行前钉齐;勿把选型推迟到 design-step。
* 可选:步骤参数声明。
* 普通节点:required 须在进执行前钉齐。
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
*/
params?: ModuleParamSpec[];
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
@@ -191,15 +243,21 @@ export type CreationFlowUserView = {
id: string;
name: string;
depends_on: string[];
/** 同能力第几次(>1 时 UI 可标「再来」) */
/** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */
occurrence?: number;
/** fresh=从零新建;revise=回头修改 */
mode?: CreationFlowStepMode;
/** 回头修改时:被改的既有步骤 id */
revises?: string;
/** 目录里的短声明(有则展示) */
declaration?: string;
repeatable?: boolean;
/** 本步调用参数(编排期钉死) */
/** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */
params?: CreationFlowStepParams;
/** 参数缺必填项时的提示(给人看) */
/** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */
paramsMissing?: string[];
/** 节点特性(如先验产物) */
kind?: ModuleNodeKind;
/** 已执行 / 将要执行 / 未执行 */
runState: FlowStepRunState;
}>;
@@ -241,11 +299,12 @@ export function normalizeStepParams(
return Object.keys(out).length > 0 ? out : undefined;
}
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序) */
/** 目录声明的必填参数中,本步仍缺失的 key(按声明顺序)。〔先验产物〕不拦执行,恒为 []。 */
export function missingRequiredStepParams(
step: Pick<CreationFlowStep, "params">,
module: ModuleCatalogEntry | null | undefined,
): string[] {
if (isPriorArtifactModule(module)) return [];
const specs = module?.params ?? [];
if (specs.length === 0) return [];
const params = step.params ?? {};
@@ -278,14 +337,120 @@ export function formatStepParamsForPrompt(
.join("\n");
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#2、name#3… */
const PRIOR_ARTIFACT_CONTEXT_TITLE =
"## 【规划产物】编排器预先为本步规划的产物内容(先验;缺则步内钉;步内可修订)";
/** 〔先验产物〕注入块:规划可空,身份写在片段自身。 */
export function formatPriorArtifactContext(
params: CreationFlowStepParams | null | undefined,
): string {
const body = formatStepParamsForPrompt(params);
const note =
!params || Object.keys(params).length === 0
? "编排器尚未规划本步具体写什么。先在本步与用户钉「写什么」,再填产物。"
: "以上为先验规划,不是锁死合同。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。";
return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}`;
}
const REVISE_MODE_ALIASES = new Set([
"revise",
"amend",
"edit",
"修改",
"回头修改",
"继承修改",
]);
const FRESH_MODE_ALIASES = new Set([
"fresh",
"new",
"create",
"新建",
"再来",
"再来一次",
]);
/** 解析 DAG 步骤的 mode;无法识别则视为缺省(fresh) */
export function parseCreationFlowStepMode(raw: unknown): CreationFlowStepMode | undefined {
if (typeof raw !== "string") return undefined;
const key = raw.trim().toLowerCase();
if (!key) return undefined;
if (REVISE_MODE_ALIASES.has(key) || REVISE_MODE_ALIASES.has(raw.trim())) {
return "revise";
}
if (FRESH_MODE_ALIASES.has(key) || FRESH_MODE_ALIASES.has(raw.trim())) {
return "fresh";
}
return undefined;
}
export function isReviseStep(
step: Pick<CreationFlowStep, "mode"> | null | undefined,
): boolean {
return step?.mode === "revise";
}
function parseStepRevisesRef(raw: Record<string, unknown>): string | undefined {
for (const key of ["revises", "revise_of", "revises_id"] as const) {
const v = raw[key];
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
type DraftCreationFlowStep = {
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
mode?: CreationFlowStepMode;
revises?: string;
};
function withStepModeFields(
step: DraftCreationFlowStep,
): Pick<CreationFlowStep, "mode" | "revises"> {
return {
...(step.mode === "revise" ? { mode: "revise" as const } : {}),
...(step.revises ? { revises: step.revises } : {}),
};
}
/**
* 回头修改步:补 revises(最近一条同名更前步),并继承其 params(本步已写的优先)。
*/
export function normalizeReviseSteps(
steps: CreationFlowStep[],
): CreationFlowStep[] {
return steps.map((step, i) => {
if (step.mode !== "revise") return step;
let revises = step.revises?.trim() || "";
if (!revises) {
for (let j = i - 1; j >= 0; j--) {
if (steps[j]?.name === step.name) {
revises = steps[j]!.id;
break;
}
}
}
const origin = revises
? steps.find((s) => s.id === revises || s.name === revises)
: undefined;
const inherited = origin?.params;
const params =
inherited && Object.keys(inherited).length
? { ...inherited, ...(step.params ?? {}) }
: step.params;
return {
...step,
...(revises ? { revises } : {}),
...(params && Object.keys(params).length ? { params } : {}),
};
});
}
/** 为缺 id 的步骤补齐唯一 id;同 name 多次 → name#n;回头修改 → name·改 */
export function ensureCreationFlowStepIds(
steps: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}>,
steps: Array<DraftCreationFlowStep>,
): CreationFlowStep[] {
const used = new Set<string>();
const nameCount = new Map<string, number>();
@@ -295,10 +460,20 @@ export function ensureCreationFlowStepIds(
const name = raw.name.trim();
const n = (nameCount.get(name) ?? 0) + 1;
nameCount.set(name, n);
const revise = raw.mode === "revise";
let id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
id = n === 1 ? name : `${name}#${n}`;
if (revise) {
id = n === 1 ? `${name}·改` : `${name}·改`;
if (used.has(id)) {
let i = 2;
while (used.has(`${name}·改#${i}`)) i++;
id = `${name}·改#${i}`;
}
} else {
id = n === 1 ? name : `${name}#${n}`;
}
}
if (used.has(id)) {
let i = 2;
@@ -311,9 +486,10 @@ export function ensureCreationFlowStepIds(
name,
depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean),
...(raw.params ? { params: raw.params } : {}),
...withStepModeFields(raw),
});
}
return out;
return normalizeReviseSteps(out);
}
/** 按 id 或(唯一)name 解析步骤引用 */
@@ -378,12 +554,7 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
const stepsRaw = row.steps;
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) return null;
const drafted: Array<{
id?: string;
name: string;
depends_on: string[];
params?: CreationFlowStepParams;
}> = [];
const drafted: DraftCreationFlowStep[] = [];
for (const item of stepsRaw) {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const s = item as Record<string, unknown>;
@@ -395,7 +566,17 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow
? depsRaw.map((d) => String(d).trim()).filter(Boolean)
: [];
const params = normalizeStepParams(s.params);
drafted.push({ id, name, depends_on, ...(params ? { params } : {}) });
const mode =
parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent);
const revises = parseStepRevisesRef(s);
drafted.push({
id,
name,
depends_on,
...(params ? { params } : {}),
...(mode ? { mode } : {}),
...(revises ? { revises } : {}),
});
}
const steps = ensureCreationFlowStepIds(drafted);
@@ -440,6 +621,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
? m.opening.trim()
: undefined;
const repeatable = m.repeatable === true;
const closer = m.closer === true;
const auto = m.auto === true;
const kind = parseModuleNodeKind(m.kind);
const params = parseModuleParamSpecs(m.params);
modules.push({
id,
@@ -447,6 +631,9 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
declaration,
artifact,
...(repeatable ? { repeatable: true } : {}),
...(closer ? { closer: true } : {}),
...(auto ? { auto: true } : {}),
...(kind ? { kind } : {}),
...(opening ? { opening } : {}),
...(params ? { params } : {}),
});
@@ -702,7 +889,9 @@ export function mergeCreationFlowPreservingAccepted(params: {
const changed =
current.name !== frozen.name ||
JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) ||
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null);
JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) ||
(current.mode ?? "fresh") !== (frozen.mode ?? "fresh") ||
(current.revises ?? "") !== (frozen.revises ?? "");
if (changed) {
steps[at] = frozen;
restored.push(frozen.id);
@@ -795,29 +984,49 @@ export async function loadModuleCatalog(
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
const lines = catalog.modules.map((m) => {
const flags = m.repeatable ? "〔可反复〕" : "";
const flags = [
m.repeatable ? "〔可反复〕" : "",
m.closer ? "〔收口〕" : "",
m.auto ? "〔程序步〕" : "",
m.kind ? MODULE_NODE_KIND_FLAGS[m.kind] : "",
]
.filter(Boolean)
.join("");
const parts: string[] = [`- ${m.name}${flags}:${m.declaration}`];
if (m.when) parts.push(` 何时用:${indentMultiline(m.when, " ")}`);
if (m.when_not) parts.push(` 何时不用:${indentMultiline(m.when_not, " ")}`);
if (m.boundary) parts.push(` 边界:${indentMultiline(m.boundary, " ")}`);
if (m.params && m.params.length > 0) {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
if (isPriorArtifactModule(m)) {
parts.push(
` 规划产物字段(可提前写入 params;空则步内钉):${m.params
.map((p) => {
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label}${hint})`;
})
.join(";")}`,
);
} else {
parts.push(
` 编排参数:${m.params
.map((p) => {
const req = p.required ? "必填" : "可选";
const hint = p.hint ? `,${p.hint}` : "";
return `${p.key}(${p.label},${req}${hint})`;
})
.join(";")}`,
);
}
}
return parts.join("\n");
});
return [
"【能力 · 可选工序】",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta);有「编排参数」的步骤必须在 DAG 里写齐 params,缺参时用 askUser 选项+其它,禁止空壳进执行。",
"生成规则:改 target 时必须同步改 rule_id(英文 kebab-case)与步骤 id(生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:选定后结束创作并保存,排在细化终稿之后,其后不要再追加步骤;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。",
"标〔先验产物〕的步骤(如生成规则、具体实例):须先定「写什么」。编排器可以提前规划多条并写入 params——这是正确的;执行时程序注入【规划产物】。规划可空,「生成什么」在步内钉。禁止为钉对象而 askUser 拦在确认开干前。规划或修订对象时同步改 rule_id(英文 kebab-case)与步骤 id(如 生成规则·{对象}),禁止只改中文沿用旧后台 id。",
"其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。",
"两种「再来一次」必须分开:〔可反复〕再编入新 id(mode 缺省/fresh)= 彻底新建一条;回头修改已完成节点 = mode=revise + revises=原步 id,继承旧产物继续改。禁止把回头修改写成又一条 fresh。",
"不要把能力执行全文塞进本步;执行由 design-step 注入。",
lines.join("\n"),
].join("\n");
@@ -1153,8 +1362,8 @@ export function formatFlowProgressForAgent(params: {
repeatable: boolean,
) => {
const flag = repeatable
? "〔可反复:可再追加不同 id〕"
: "〔不可反复:禁止再排一次执行〕";
? "〔可反复:mode=fresh 再追加 = 彻底新建;要改旧条用 mode=revise〕"
: "〔不可反复:禁止再排新建;要改已完成节点用 mode=revise 回头修改〕";
const idBit = id ? `(id: ${id})` : "";
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
doneNames.add(name);
@@ -1196,9 +1405,12 @@ export function formatFlowProgressForAgent(params: {
const draftLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (doneIds.has(step.id) || doneNames.has(step.name)) continue;
if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue;
const reviseBit = isReviseStep(step)
? ` · 回头修改${step.revises ? `(revises: ${step.revises})` : ""}`
: "";
draftLines.push(
`- ${step.name}(id: ${step.id})· 已在草案,保留原 id;不要当作新规划再写一遍`,
`- ${step.name}(id: ${step.id})${reviseBit} · 已在草案,保留原 id;不要当作新规划再写一遍`,
);
}
}
@@ -1210,9 +1422,9 @@ export function formatFlowProgressForAgent(params: {
const lines = [
"【流程进度】(程序钉死,必须遵守)",
"你的任务是追加缺口,不是从头规划。",
"已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。",
"草案里已有的步骤:原样保留,不要重排一遍开局。",
"只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。",
"已完成的非反复技能:禁止再排一次「新建」(mode=fresh)。用户要改已完成节点 → 追加新 id,mode=revise,revises=原步 id;程序会继承既有产物。",
"〔可反复〕再编入新 id 且 mode 缺省/fresh = 彻底新建一条(新对象/新批次),不是改旧的。要改已有那一条 → 同样用 mode=revise。",
"草案里已有的步骤:原样保留,不要重排一遍开局。回头修改步插在最近已验收步之后、尚未执行的步之前。",
];
if (doneLines.length) {
lines.push("", "已完成:", ...doneLines);
@@ -1258,10 +1470,7 @@ export function validateCreationFlow(
const allowed = catalog
? new Set(catalog.modules.map((m) => m.name))
: null;
const nameCount = new Map<string, number>();
for (const step of flow.steps) {
nameCount.set(step.name, (nameCount.get(step.name) ?? 0) + 1);
}
const reportedFreshDup = new Set<string>();
for (let i = 0; i < flow.steps.length; i++) {
const step = flow.steps[i]!;
@@ -1275,14 +1484,44 @@ export function validateCreationFlow(
}
const mod = catalog?.modules.find((m) => m.name === step.name);
if (
if (isReviseStep(step)) {
const ref = step.revises?.trim() ?? "";
if (!ref) {
errors.push(
`「${step.id}」是回头修改,但未标明 revises(应指向更前的同名步骤 id)`,
);
} else {
const origin = findStepByRef(flow, ref);
if (!origin) {
errors.push(
`「${step.id}」回头修改「${ref}」,但流程中没有该步骤`,
);
} else if (origin.id === step.id) {
errors.push(`「${step.id}」不能回头修改自己`);
} else if (origin.name !== step.name) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但能力名不同(${step.name} ≠ ${origin.name})`,
);
} else {
const originIndex = flow.steps.findIndex((s) => s.id === origin.id);
if (originIndex >= i) {
errors.push(
`「${step.id}」回头修改「${origin.id}」,但「${origin.id}」未排在其前面`,
);
}
}
}
} else if (
catalog &&
(nameCount.get(step.name) ?? 0) > 1 &&
mod &&
mod.repeatable !== true
mod.repeatable !== true &&
!reportedFreshDup.has(step.name) &&
flow.steps.filter((s) => s.name === step.name && !isReviseStep(s)).length >
1
) {
reportedFreshDup.add(step.name);
errors.push(
`「${step.name}」出现多次,但目录未标 repeatable(仅可反复能力可同名多次)`,
`「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能要改旧稿请用 mode=revise)`,
);
}
@@ -1306,7 +1545,7 @@ export function validateCreationFlow(
return { ok: errors.length === 0, errors };
}
/** 进 design-step 前:本步必填编排参数是否已齐 */
/** 进 design-step 前:本步必填编排参数是否已齐。〔先验产物〕不拦。 */
export function validateStepReadyToRun(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
@@ -1362,14 +1601,19 @@ export function formatCreationFlowForUser(
const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const),
);
const seenName = new Map<string, number>();
const seenFreshName = new Map<string, number>();
const next = nextPendingStep(flow, acceptedStepIds);
return {
brief: flow.brief,
status: flow.status,
steps: flow.steps.map((s, i) => {
const n = (seenName.get(s.name) ?? 0) + 1;
seenName.set(s.name, n);
const revise = isReviseStep(s);
let occurrence: number | undefined;
if (!revise) {
const n = (seenFreshName.get(s.name) ?? 0) + 1;
seenFreshName.set(s.name, n);
occurrence = n;
}
const mod = decl.get(s.name);
const paramsMissing = missingRequiredStepParams(s, mod);
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
@@ -1382,9 +1626,12 @@ export function formatCreationFlowForUser(
id: s.id,
name: s.name,
depends_on: s.depends_on,
occurrence: n,
occurrence,
...(revise ? { mode: "revise" as const } : {}),
...(s.revises ? { revises: s.revises } : {}),
declaration: mod?.declaration,
repeatable: mod?.repeatable,
...(mod?.kind ? { kind: mod.kind } : {}),
runState,
...(s.params ? { params: s.params } : {}),
...(paramsMissing.length > 0 ? { paramsMissing } : {}),
@@ -1443,6 +1690,7 @@ export function nextPendingStep(
acceptedStepIds: readonly string[],
): CreationFlowStep | null {
if (!flow?.steps.length) return null;
const ready: CreationFlowStep[] = [];
for (const step of flow.steps) {
if (isStepAccepted(step, acceptedStepIds)) continue;
const depsOk = step.depends_on.every((dep) => {
@@ -1450,9 +1698,9 @@ export function nextPendingStep(
if (!depStep) return false;
return isStepAccepted(depStep, acceptedStepIds);
});
if (depsOk) return step;
if (depsOk) ready.push(step);
}
return null;
return ready.find((s) => isReviseStep(s)) ?? ready[0] ?? null;
}
/** 当前已列出的步骤是否都已验收(不管 status) */
@@ -1532,8 +1780,10 @@ export type DesignStepBinding = {
module: ModuleCatalogEntry;
depTags: string[];
modulePrompt: string;
/** 程序开场白;无则本步直接调 LLM */
/** 程序开场白;无则本步直接调 LLM。回头修改不抛 opening。 */
opening: string | null;
/** 回头修改时注入的既有产物 tag */
inheritTag: string | null;
};
/**
@@ -1570,7 +1820,9 @@ export async function resolveDesignStepBinding(params: {
`# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`;
const opening =
module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
isReviseStep(step) || module.auto
? null
: module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null;
return {
step,
@@ -1578,5 +1830,6 @@ export async function resolveDesignStepBinding(params: {
depTags: dependencyArtifactTags(step, catalog, flow),
modulePrompt: formatModulePromptForLlm(modulePromptRaw),
opening,
inheritTag: isReviseStep(step) ? module.artifact : null,
};
}
+13 -4
View File
@@ -31,6 +31,7 @@ import {
slotOrderForRef,
} from "./context-order.js";
import type { ContextSegmentDef } from "./context-segments.js";
import { PLAY_VISIBLE_BODY_INSTRUCTION } from "./present-packet.js";
type WorkerTemplateDoc = {
id?: string;
@@ -44,8 +45,9 @@ type WorkerTemplateDoc = {
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:design 生命周期默认确认;play 缺省按 review(稳妥)
* - 未声明 acceptance:创作默认确认
*/
export function resolveAcceptanceModeForWorker(params: {
session: RuntimeSession;
@@ -56,6 +58,9 @@ export function resolveAcceptanceModeForWorker(params: {
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
if (inferLifecycleStage(params.session) === "play") {
return "no_confirmation";
}
const decl = buildInstanceWorkerDeclaration(
params.session,
@@ -66,7 +71,6 @@ export function resolveAcceptanceModeForWorker(params: {
if (entry?.acceptance === "continue") return "no_confirmation";
if (entry?.acceptance === "review") return "user_confirmed";
// 未写明:创作阶段默认验收;游玩缺省也验收(避免静默连跑)
return "user_confirmed";
}
@@ -210,6 +214,11 @@ export function buildDeclaredWorkerSkill(params: {
params.template?.duty?.trim() ||
`执行 ${id}`;
const excerpt = params.template?.prompt_excerpt?.trim() || "";
const visibleBodyRule =
id === "narrator" || id === "round-present"
? PLAY_VISIBLE_BODY_INSTRUCTION
: "";
const excerptWithRule = [excerpt, visibleBodyRule].filter(Boolean).join("\n\n");
const presentation = params.entry.presentation
? JSON.stringify(params.entry.presentation, null, 2)
: "";
@@ -217,7 +226,7 @@ export function buildDeclaredWorkerSkill(params: {
const premises = (params.workerSet?.core_premises ?? []).filter(Boolean);
const residentSection = formatResidentPromptSection(resident, id);
const personaText = [duty, excerpt].filter(Boolean).join("\n\n");
const personaText = [duty, excerptWithRule].filter(Boolean).join("\n\n");
const orderDoc = parseContextOrder(params.workerSet?.context_order);
const orderSlot = slotOrderForRef(orderDoc, id);
let contextSegments: ContextSegmentDef[] | undefined;
@@ -277,7 +286,7 @@ export function buildDeclaredWorkerSkill(params: {
"",
duty,
"",
excerpt ? `## 写法要点\n\n${excerpt}` : "",
excerptWithRule ? `## 写法要点\n\n${excerptWithRule}` : "",
presentation ? `## presentation(实例)\n\n\`\`\`json\n${presentation}\n\`\`\`` : "",
narrative ? `## 叙事指南\n\n${narrative}` : "",
premises.length
+40 -6
View File
@@ -516,7 +516,9 @@ export async function loadWorkerSkillWithContext(
resolveDesignStepBinding,
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
formatPriorArtifactContext,
formatStepParamsForPrompt,
isPriorArtifactModule,
} = await import("./creation-flow.js");
const binding = await resolveDesignStepBinding({
skillPackRoot: skill.skillPackRoot,
@@ -526,11 +528,27 @@ export async function loadWorkerSkillWithContext(
skillsRoot,
});
if (binding) {
const inheritTag = binding.inheritTag?.trim() || "";
const inheritNote = inheritTag
? `\n\n【回头修改】本步继承既有产物继续改,不是从零再生成。程序已注入【既有产物 · 继承修改】。保留未点名要改的部分,写回同一产物。禁止推倒重写,禁止再走一遍开场询问。`
: "";
const openingNote = binding.opening
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: "";
const paramsBlock = `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
modulePromptBlock = `${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}`;
const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = "";
const depProjection =
binding.module.id === "context-order" ? "index" : undefined;
if (binding.module.id === "context-order") {
const { formatPlayAgentRosterForPrompt } = await import("./play-slots.js");
rosterBlock = `${formatPlayAgentRosterForPrompt()}\n\n`;
}
modulePromptBlock = `${rosterBlock}${paramsBlock}\n\n## 【本步方法 · ${binding.module.name}】\n\n${binding.modulePrompt.trim()}${openingNote}${inheritNote}`;
const depTags = inheritTag
? binding.depTags.filter((tag) => tag !== inheritTag)
: binding.depTags;
const baseInputs = [
"用户.需求",
"book.brief",
@@ -540,16 +558,29 @@ export async function loadWorkerSkillWithContext(
"设计.创作流程",
CREATION_CURRENT_STEP_TAG,
CREATION_MODULE_OPENING_TAG,
...binding.depTags,
...depTags,
...(inheritTag ? [inheritTag] : []),
];
const inputTags = [...new Set(baseInputs)];
const outputTags = [binding.module.artifact];
const depSegments = binding.depTags.map((tag, i) => ({
const depSegments = depTags.map((tag, i) => ({
id: `dep-${i}`,
tier: "static" as const,
tags: [tag],
label: `## 【依赖产物 · ${tag}】只读`,
label: `## 【依赖产物 · ${tag}】只读${depProjection === "index" ? "(仅 brief / 挂载 / 稳变)" : ""}`,
...(depProjection ? { projection: depProjection } : {}),
}));
const inheritSegment = inheritTag
? [
{
id: "inherit-existing",
tier: "static" as const,
tags: [inheritTag],
label:
"## 【既有产物 · 继承修改】在下列正文上继续改,写回同一产物;禁止从零另起一份",
},
]
: [];
const openingSegment = binding.opening
? [
{
@@ -562,13 +593,16 @@ export async function loadWorkerSkillWithContext(
: [];
patchedWorker = {
...worker,
name: `创作 · ${binding.module.name}`,
name: inheritTag
? `创作 · 回头修改 · ${binding.module.name}`
: `创作 · ${binding.module.name}`,
description: binding.module.declaration,
inputTags,
outputTags,
contextSegments: [
...(worker.contextSegments ?? []),
...openingSegment,
...inheritSegment,
...depSegments,
],
};
+200
View File
@@ -0,0 +1,200 @@
/**
* 开场白终节点:选定后落库、关 DAG。
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
*/
import {
parseTableDoc,
stringifyTableDoc,
type TableDoc,
} from "../blackboard/table-cells.js";
import {
parseCreationFlow,
stringifyCreationFlow,
} from "./creation-flow.js";
import { parseContextFragment } from "./context-fragment.js";
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
export const OPENING_OUTPUT_TAG = "输出.开场白";
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
export const CREATION_SEALED_WAITING_MESSAGE =
"开场已选定,创作已收口并保存。可切换到「游玩」。";
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)";
export function isOpeningLockTag(tag: string): boolean {
const t = tag.trim();
return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG;
}
export function isOpeningSealArtifact(artifact: {
workerId?: string;
outputTags?: readonly string[];
}): boolean {
if ((artifact.workerId ?? "").trim() === "opening-generator") return true;
return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG);
}
export type OpeningVariable = {
name: string;
value: unknown;
note?: string;
};
export type OpeningSealPayload = {
candidates: string[];
selectedIndex: number;
selectedText: string;
variables: OpeningVariable[];
};
function asTrimmed(v: unknown): string | undefined {
if (typeof v !== "string") return undefined;
const t = v.trim();
return t || undefined;
}
function collectCandidateTexts(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
if (typeof item === "string" && item.trim()) {
out.push(item.trim());
continue;
}
if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text =
asTrimmed(row.全文) ||
asTrimmed(row.text) ||
asTrimmed(row.开场白) ||
asTrimmed(row.内容);
if (text) out.push(text);
}
}
return out;
}
function collectVariables(raw: unknown): OpeningVariable[] {
if (!Array.isArray(raw)) return [];
const out: OpeningVariable[] = [];
for (const item of raw) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const name =
asTrimmed(row.名) || asTrimmed(row.name) || asTrimmed(row.key) || "";
if (!name) continue;
const note = asTrimmed(row.依据) || asTrimmed(row.note);
out.push({
name,
value: row.值 !== undefined ? row.值 : row.value,
...(note ? { note } : {}),
});
}
return out;
}
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
return null;
}
return fragmentBody as Record<string, unknown>;
}
/** 从开场白 fragment(或裸 JSON)抽出候选与变量。 */
export function parseOpeningSealPayload(
raw: string | null | undefined,
selectedIndex = 0,
): OpeningSealPayload | null {
if (!raw?.trim()) return null;
const fragment = parseContextFragment(raw);
const body = bodyRecord(fragment?.正文) ?? (() => {
try {
const parsed = JSON.parse(raw) as unknown;
return bodyRecord(parsed) ?? bodyRecord((parsed as { 正文?: unknown })?.正文);
} catch {
return null;
}
})();
const primary =
asTrimmed(body?.开场白全文) ||
asTrimmed(body?.["输出.开场白"]) ||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined);
const fromList = collectCandidateTexts(body?.开场白候选);
const candidates: string[] = [];
const seen = new Set<string>();
const push = (text: string | undefined) => {
if (!text || seen.has(text)) return;
seen.add(text);
candidates.push(text);
};
push(primary);
for (const t of fromList) push(t);
if (candidates.length === 0) return null;
const max = candidates.length - 1;
const idx = Number.isFinite(selectedIndex)
? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
: 0;
return {
candidates,
selectedIndex: idx,
selectedText: candidates[idx] ?? candidates[0]!,
variables: collectVariables(body?.开场变量),
};
}
export function openingVariablesToTableDoc(
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): TableDoc {
const now = new Date().toISOString();
return {
rows: variables.map((v) => ({
key: v.name,
value: v.value,
rev: 1,
updatedAt: now,
source,
visibility: "visible" as const,
...(v.note ? { note: v.note } : {}),
})),
};
}
export function mergeOpeningTablePatch(
currentRaw: string | null | undefined,
variables: readonly OpeningVariable[],
source = "worker:opening-setup",
): string | null {
if (!variables.length) return null;
const patch = openingVariablesToTableDoc(variables, source);
const current = parseTableDoc(currentRaw);
if (!current) return stringifyTableDoc(patch);
const byKey = new Map(current.rows.map((r) => [r.key, r]));
const now = new Date().toISOString();
for (const row of patch.rows) {
const prev = byKey.get(row.key);
if (prev && String(prev.source).startsWith("user")) continue;
byKey.set(row.key, {
...row,
rev: (prev?.rev ?? 0) + 1,
updatedAt: now,
});
}
return stringifyTableDoc({ rows: [...byKey.values()] });
}
export function closeCreationFlowRaw(raw: string | null | undefined): string | null {
const flow = parseCreationFlow(raw);
if (!flow) return raw?.trim() ? raw : null;
if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" });
}
+51
View File
@@ -319,3 +319,54 @@ export function inferPlaySlotsFromWorkers(
chance: hasChance,
};
}
const AGENT_ID_ALIASES: Record<string, PlaySlotId | OnDemandSlotId> = {
auditor: "auditor",
旁观维护: "auditor",
表格管理: "auditor",
gm: "gm",
"world-simulator": "gm",
主世界层: "gm",
narrator: "narrator",
叙事转述: "narrator",
转述: "narrator",
perspective: "perspective",
"role-decide": "perspective",
角色视角: "perspective",
chance: "chance",
机遇裁定: "chance",
};
/** 把模型写的 id / 中文名 / worker ref 收成槽键 */
export function resolvePlayAgentId(
raw: string,
): PlaySlotId | OnDemandSlotId | undefined {
const t = raw.trim();
return AGENT_ID_ALIASES[t] ?? AGENT_ID_ALIASES[t.toLowerCase()];
}
/** 注入投影排序等晚段步骤:名册由程序钉死,模型只抄不发明。 */
export function formatPlayAgentRosterForPrompt(): string {
const lines = [
"## 【本局固定执行单元 · 程序钉死】",
"",
"禁止增删改名,禁止发明未入白名单的槽(如画图)。先抄名册,再给每个已启用者分配上下文 inserts。",
"",
"每轮管线(顺序固定):",
];
for (const id of PLAY_SLOT_ORDER) {
const meta = PLAY_SLOT_META[id];
const def = id === "perspective" ? "默认关" : "默认开";
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_PLAY_SLOT_REFS[id]}\` **${meta.label}**(${def}):${meta.purpose}`,
);
}
lines.push("", "按需(不进每轮;enabled 时可空 inserts):");
for (const id of Object.keys(DEFAULT_ON_DEMAND_REFS) as OnDemandSlotId[]) {
const meta = ON_DEMAND_SLOT_META[id];
lines.push(
`- \`${id}\` → ref=\`${DEFAULT_ON_DEMAND_REFS[id]}\` **${meta.label}**(默认关):${meta.purpose}`,
);
}
return lines.join("\n");
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 游玩回合:按运行规格 playWorkerIds 连跑(旁观 → 视角? → 主世界层 → 转述)。
* 与创作总管无关;收口后的输入走这里。
*/
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive";
export const PLAY_WORKING_SNAPSHOT_ID = "play-working";
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
export function isPlayLayerActive(slots: Record<string, unknown> | undefined): boolean {
return Boolean(slots?.[SLOT_PLAY_LAYER_ACTIVE]);
}
export function readPlayTurnQueue(
slots: Record<string, unknown> | undefined,
): string[] {
const raw = slots?.[SLOT_PLAY_TURN_QUEUE];
if (!Array.isArray(raw)) return [];
return raw
.filter((id): id is string => typeof id === "string" && Boolean(id.trim()))
.map((id) => id.trim());
}
export function withPlayTurnQueue(
slots: Record<string, unknown>,
queue: string[] | undefined,
): Record<string, unknown> {
return {
...slots,
[SLOT_PLAY_TURN_QUEUE]: queue?.length ? queue : undefined,
};
}
+25 -2
View File
@@ -132,6 +132,9 @@ function normalizeBlocks(raw: unknown): PresentBlocks {
if (out.monitor === undefined && row.监控 !== undefined) {
out.monitor = row.监控 as PresentBlocks["monitor"];
}
if (typeof out.body === "string") {
out.body = stripPresentSourceFences(out.body);
}
return out;
}
@@ -170,15 +173,35 @@ export function presentFromPlainText(
return {
schema: PRESENT_SCHEMA,
shell,
blocks: { body: text },
blocks: { body: stripPresentSourceFences(text) },
};
}
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
export function stripPresentSourceFences(raw: string): string {
let s = raw.trim();
for (let i = 0; i < 3; i += 1) {
const next = s
.replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "")
.replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "")
.trim();
if (next === s) break;
s = next;
}
return s;
}
export const PLAY_VISIBLE_BODY_INSTRUCTION = [
"用户可见正文(present.v1 的 blocks.body,或纯 Markdown 主读)默认 **500~2000 字**(按汉字计)。",
"不要写成几句气泡短信就结束,除非用户明确要求极短。",
"禁止把 tag 名(如「输出.用户展示」)、压缩摘要、过程日志写进正文。",
].join("\n");
export function parsePresentPacket(
raw: string | undefined | null,
fallbackShell: PresentShellId = "prose",
): PresentPacketView {
const text = raw?.trim() ?? "";
const text = stripPresentSourceFences(raw?.trim() ?? "");
if (!text) {
return {
ok: true,
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { RuntimeSession } from "../types/runtime.js";
import { isPlayLayerActive } from "./play-turn.js";
import {
deriveDesignStageScope,
deriveOnDemandWorkerScope,
@@ -45,8 +46,9 @@ export function canEnterPlay(session: RuntimeSession): boolean {
}
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
const override = session.slots.uiLifecycleStage;
if (override === "play" && canEnterPlay(session)) return "play";
if (!canEnterPlay(session)) return "design";
if (session.slots.uiLifecycleStage === "play") return "play";
if (isPlayLayerActive(session.slots)) return "play";
return "design";
}
+7 -19
View File
@@ -3,16 +3,13 @@ import {
OpenAiCompatibleProvider,
type LlmProvider,
} from "../llm/client.js";
import { PresetLlmProvider } from "../llm/preset-wrapper.js";
import { resolveActivePreset } from "../preset/store.js";
import { loadAppSettings } from "../config/settings.js";
import {
TokenTrackingProvider,
type LlmTrackingContext,
} from "../llm/token-tracker.js";
wrapLlmForSession,
type LlmTrackingRef,
} from "../llm/preset-wrapper.js";
import type { ParsedWorkerSkill, SkillWorkerLlmBindings } from "./types.js";
type LlmTrackingRef = { current: LlmTrackingContext };
export type { LlmTrackingContext } from "../llm/token-tracker.js";
/** 按 ApiProfile.id 构建 LLM;找不到 profile 时回退 fallback */
export function createLlmForProfileId(
@@ -25,17 +22,10 @@ export function createLlmForProfileId(
return fallback;
}
let inner: LlmProvider = new OpenAiCompatibleProvider(
profileToLlmConfig(profile),
return wrapLlmForSession(
new OpenAiCompatibleProvider(profileToLlmConfig(profile)),
trackingRef,
);
const preset = resolveActivePreset(loadAppSettings().activePresetId);
if (preset) {
inner = new PresetLlmProvider(inner, () =>
resolveActivePreset(loadAppSettings().activePresetId),
);
}
if (!trackingRef) return inner;
return new TokenTrackingProvider(inner, () => trackingRef.current);
}
/**
@@ -142,5 +132,3 @@ export function filterInputsForRolePerspective(
}
return filtered;
}
export type { LlmTrackingContext };
+44 -5
View File
@@ -1,3 +1,5 @@
import type { GenerationParameters } from "./preset.js";
/** 一次 LLM 调用实际发送的上下文(全量,供右键观察) */
export type LlmContextMessage = {
role: string;
@@ -12,17 +14,51 @@ export type LlmContextTrace = {
/** 便于列表展示 */
charCount: number;
model?: string;
/** 与请求一并送出的生成参数(无则未带) */
generation?: GenerationParameters;
};
/** 把即将写入请求 body.messages 的条目冻成痕迹(含 tool_calls 等附加字段) */
export function snapshotRequestMessages(
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>,
): LlmContextMessage[] {
return messages.map((m) => {
const hasExtras = Boolean(m.tool_calls) || Boolean(m.tool_call_id);
if (!hasExtras) {
return {
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
};
}
return {
role: m.role,
content: JSON.stringify({
role: m.role,
content: m.content ?? "",
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
}),
};
});
}
export function buildContextTrace(params: {
caller: string;
messages: Array<{ role: string; content: string }>;
messages: Array<{
role: string;
content?: string | null;
tool_calls?: unknown;
tool_call_id?: string;
}>;
model?: string;
generation?: GenerationParameters;
}): LlmContextTrace {
const messages = params.messages.map((m) => ({
role: m.role,
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
}));
const messages = snapshotRequestMessages(params.messages);
const charCount = messages.reduce((n, m) => n + m.content.length, 0);
return {
caller: params.caller,
@@ -30,6 +66,9 @@ export function buildContextTrace(params: {
messages,
charCount,
model: params.model,
...(params.generation && Object.keys(params.generation).length
? { generation: params.generation }
: {}),
};
}
+11 -2
View File
@@ -53,7 +53,10 @@ export type WaitingReason =
} // worker 无产物时的阻塞提问
| { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑
| {
/** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */
/**
* 旧路径:上一步验收后先问「下一步想写什么」。
* 现已改为验收后直接提案;保留此 kind 以兼容未完成的旧会话。
*/
kind: "next_intent";
afterWorkerId?: string;
afterUnitId?: string;
@@ -329,7 +332,13 @@ export type PhaseEffect =
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
| { type: "propose_next_creation_step" };
| { type: "propose_next_creation_step" }
/** 选定开场白:落库、关 DAG、标记创作收口 */
| { type: "seal_creation_opening" }
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
| { type: "run_play_turn" }
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
| { type: "continue_play_turn" };
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
export type ApplyEventResult = {
+129 -80
View File
@@ -11,9 +11,16 @@ import type { ParsedWorkerSkill } from "../skills/types.js";
import {
extractJsonObjectText,
isUsableWorkerSet,
looksLikeProseNotSpec,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import { isPlayLayerActive } from "../skills/play-turn.js";
import { loadAppSettings } from "../config/settings.js";
import { resolveActivePreset } from "../preset/store.js";
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
import { PRESENT_TAG } from "../skills/present-packet.js";
import {
mergeQuestionsPreferFragment,
normalizeQuestions,
@@ -21,11 +28,15 @@ import {
} from "../skills/question-protocol.js";
import {
extractFragmentAskSidecar,
expectsContextFragmentTag,
isUsableContextFragment,
looksLikeFragmentDoc,
} from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js";
import {
CONTEXT_ORDER_TAG,
parseContextOrder,
serializeContextOrder,
} from "../skills/context-order.js";
export type WorkerRunParams = {
skillName: string;
@@ -53,6 +64,25 @@ export type WorkerRunResult = {
};
const WORKER_SET_OUTPUT_TAGS = new Set(["设计.worker集", "设计.worker集.草稿"]);
const PLAY_FEED_OUTPUT_TAGS = new Set([PRESENT_TAG, "输出.开场白"]);
function formatWorkerPreview(
outputs: Record<string, string>,
summary: string,
): string {
const visible = Object.entries(outputs).filter(([tag]) =>
PLAY_FEED_OUTPUT_TAGS.has(tag),
);
if (visible.length) {
return visible.map(([, v]) => v).join("\n\n");
}
return (
Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary
);
}
const WORKER_OUTPUT_INSTRUCTION = `
@@ -74,7 +104,9 @@ askUser 每项可为:
- outputs 的 key 必须是要求的 outputTags
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
@@ -193,31 +225,46 @@ function recoverFragmentOutputs(
}
}
function dropUnusableFragmentOutputs(outputs: Record<string, string>): void {
/** 进度指针不得当产物;半残 JSON / 缺字段仍保留,交给验收卡。 */
function dropProgressPointerOutputs(outputs: Record<string, string>): void {
for (const tag of Object.keys(outputs)) {
if (isProgressPointerTag(tag)) {
delete outputs[tag];
continue;
}
const content = outputs[tag]!;
const requireFragment = expectsContextFragmentTag(tag);
const looksFragment =
isUsableContextFragment(content) ||
looksLikeFragmentDoc(tryParseObject(content)) ||
/"schema"\s*:\s*"context-fragment\.v1"/.test(content) ||
/"技能"\s*:/.test(content);
if ((requireFragment || looksFragment) && !isUsableContextFragment(content)) {
if (isProgressPointerTag(tag) || !outputs[tag]?.trim()) {
delete outputs[tag];
}
}
}
function tryParseObject(text: string): unknown {
const extracted = extractJsonObjectText(text) ?? text.trim();
try {
return JSON.parse(extracted);
} catch {
return undefined;
/** 像结构化残稿(含截断 JSON),应进验收而不是改成提问。 */
function looksLikeStructuredDraft(text: string): boolean {
const t = text.trim();
if (!t) return false;
if (t.startsWith("{") || t.startsWith("[")) return true;
if (/```(?:json)?/i.test(t) && t.includes("{")) return true;
if (/"schema"\s*:/.test(t)) return true;
if (/"技能"\s*:/.test(t) || /"正文"\s*:/.test(t)) return true;
if (/"inserts"\s*:/.test(t) || /"agents"\s*:/.test(t) || /"play_slots"\s*:/.test(t)) {
return true;
}
if (/"workers"\s*:/.test(t) && t.includes("{")) return true;
return Boolean(extractJsonObjectText(t));
}
/** 几乎只有提问、没有稿。 */
function isQuestionOnlyText(text: string): boolean {
const t = text.trim();
if (!t) return true;
if (looksLikeStructuredDraft(t)) return false;
const qs = extractQuestionsFromText(t);
if (!qs.length) return false;
return looksLikeProseNotSpec(t) || /你的选择|请(?:描述|选择|确认|补充)/.test(t);
}
function canonicalizeKnownOutputs(outputs: Record<string, string>): void {
const raw = outputs[CONTEXT_ORDER_TAG];
if (!raw?.trim()) return;
const doc = parseContextOrder(raw);
if (doc?.slots.length) {
outputs[CONTEXT_ORDER_TAG] = serializeContextOrder(doc);
}
}
@@ -239,20 +286,14 @@ function parseWorkerResponse(
try {
parsed = JSON.parse(raw);
} catch {
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return {
outputs: {},
summary: "未产出合法协议 JSON",
preview: raw.slice(0, 600),
askUser: questions.length
? questions.slice(0, 2)
: normalizeQuestions([
"请补充设计所需的关键信息(上一次未产出合法 JSON 规格)。",
]),
};
}
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
{ summary: "格式不完整,已交出残稿" },
outputTags,
);
}
if (target && isUsableContextFragment(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
@@ -260,6 +301,13 @@ function parseWorkerResponse(
outputTags,
);
}
if (target && raw.trim() && !isQuestionOnlyText(raw)) {
return finalizeParsedOutputs(
{ [target]: raw },
{ summary: "未按协议包一层,已交出原文" },
outputTags,
);
}
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return {
outputs: {},
@@ -323,7 +371,7 @@ function finalizeParsedOutputs(
}
}
// 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop
// 先抽追问;半残稿仍留在 outputs,追问挂验收卡(不因缺字段丢掉正文)
let askAssessment: string | undefined;
const fragQuestions: QuestionItem[] = [];
for (const content of Object.values(outputs)) {
@@ -339,7 +387,8 @@ function finalizeParsedOutputs(
askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
}
dropUnusableFragmentOutputs(outputs);
dropProgressPointerOutputs(outputs);
canonicalizeKnownOutputs(outputs);
if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
@@ -351,11 +400,7 @@ function finalizeParsedOutputs(
: Object.values(outputs)[0]?.slice(0, 80) ??
(askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成");
const preview =
Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
const preview = formatWorkerPreview(outputs, summary);
return sanitizeWorkerSetOutputs({
outputs,
@@ -366,12 +411,11 @@ function finalizeParsedOutputs(
});
}
/** 规格 tag 必须是可用 JSON;非法散文改为 askUser,避免污染黑板 */
/** 规格 tag:能解析则收成 JSON;半残 JSON 保留进验收;纯提问才改成 askUser。 */
export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResult {
const outputs = { ...result.outputs };
const askUser = [...(result.askUser ?? [])];
const askAssessment = result.askAssessment;
let droppedProse = false;
for (const tag of [...Object.keys(outputs)]) {
if (!WORKER_SET_OUTPUT_TAGS.has(tag)) continue;
@@ -388,27 +432,16 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu
: (extractJsonObjectText(extracted) ?? extracted);
continue;
}
droppedProse = true;
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
if (looksLikeStructuredDraft(content)) {
outputs[tag] = content.trim();
continue;
}
}
if (droppedProse && askUser.length === 0) {
askUser.push(
...normalizeQuestions([
"请补充或确认开局关键前提(上一次把说明文字写进了规格字段,未产出合法 JSON)。",
]),
);
}
// 提问时不要夹带半残规格
if (askUser.length > 0) {
for (const tag of WORKER_SET_OUTPUT_TAGS) {
// 保留仍合法的草稿;已在上面删掉非法的
void tag;
if (isQuestionOnlyText(content)) {
delete outputs[tag];
if (askUser.length === 0) {
const qs = normalizeQuestions(extractQuestionsFromText(content));
if (qs.length) askUser.push(...qs.slice(0, 2));
}
}
}
@@ -420,10 +453,7 @@ export function sanitizeWorkerSetOutputs(result: WorkerRunResult): WorkerRunResu
const preview =
askUser.length && Object.keys(outputs).length === 0
? askUser.map((q) => `- ${q.prompt}`).join("\n")
: Object.entries(outputs)
.map(([tag, v]) => `### ${tag}\n\n${v}`)
.join("\n\n")
.slice(0, 4000) || summary;
: formatWorkerPreview(outputs, summary);
return {
outputs,
@@ -527,22 +557,41 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
inputs[CONTEXT_BRIEF_TAG] = priorBrief;
}
const userPayload = assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
workerId: worker.id,
workerName: worker.name,
outputTags: worker.outputTags,
});
const systemContent = promptBody + WORKER_OUTPUT_INSTRUCTION;
const playPreset = isPlayLayerActive(params.slots)
? resolveActivePreset(loadAppSettings().activePresetId)
: null;
const messages = playPreset
? assemblePlayWorkerMessages({
systemPrompt: systemContent,
preset: playPreset,
pack: worldInfoPackFromSegments({
segments: worker.contextSegments ?? [],
inputs,
blackboard: params.blackboard,
inputMerge,
}),
})
: [
{ role: "system" as const, content: systemContent },
{
role: "user" as const,
content: assembleWorkerContext({
inputs,
segments: worker.contextSegments,
blackboard: params.blackboard,
inputMerge,
workerId: worker.id,
workerName: worker.name,
outputTags: worker.outputTags,
}),
},
];
const result = await completeWorkerPreferStream(
params.llm,
[
{ role: "system", content: promptBody + WORKER_OUTPUT_INSTRUCTION },
{ role: "user", content: userPayload },
],
messages,
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
+119 -1
View File
@@ -106,9 +106,127 @@ describe("contextSegments assembly", () => {
outputTags: ["设计.美学纲领与交互范式"],
});
expect(text).toContain("待改底稿");
expect(text).toContain("用户.worker答复");
expect(text).toContain("用户要求");
expect(text).toContain("冲突时以较新的为准");
expect(text).toContain("偏冷");
expect(text).toContain("再冷一点");
expect(text.split("再冷一点").length - 1).toBe(1);
});
it("stale revision note without a draft stays as a user requirement, not revision mode", () => {
const bb = new Blackboard();
const text = assembleWorkerContext({
inputs: {
"用户.修订说明": "上一步留下的意见",
"用户.需求": "网恋对话",
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.修订说明"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text).not.toContain("修订模式");
expect(text).not.toContain("待改底稿");
expect(text).toContain("用户要求");
expect(text).toContain("上一步留下的意见");
expect(text).toContain("网恋对话");
expect(text).toContain("按 SKILL 与上方分区完成任务");
});
it("collapses duplicate user-demand tags in the same segment", () => {
const bb = new Blackboard();
const text = assembleWorkerContext({
inputs: {
"用户.需求": "开场需求\n\n需要正文和选项",
"用户.worker答复": "需要正文和选项",
"用户.最新输入": "需要正文和选项",
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.worker答复", "用户.最新输入"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text.split("需要正文和选项").length - 1).toBe(1);
});
it("keeps earlier user requirements and puts the newer revision last", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.正文组成",
content: '{"shell":"split_board"}',
source: "worker",
});
const text = assembleWorkerContext({
inputs: {
"用户.需求": "需要正文和监控",
"用户.修订说明": "状态放到上面",
"设计.正文组成": '{"shell":"split_board"}',
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.需求", "用户.修订说明"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 正文组成",
outputTags: ["设计.正文组成"],
});
expect(text).toContain("需要正文和监控");
expect(text).toContain("状态放到上面");
expect(text.indexOf("需要正文和监控")).toBeLessThan(text.indexOf("状态放到上面"));
expect(text).toContain("冲突时以较新的为准");
expect(text).toContain("待改底稿");
expect(text).toContain("split_board");
});
it("inherit-existing asks the worker to keep the draft without a revision note", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.美学纲领与交互范式",
content: '{"tone":"warm"}',
source: "worker",
});
const text = assembleWorkerContext({
inputs: {
"设计.美学纲领与交互范式": '{"tone":"warm"}',
},
segments: [
{
id: "inherit-existing",
tier: "static",
tags: ["设计.美学纲领与交互范式"],
label: "## 【既有产物 · 继承修改】在下列正文上继续改",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 回头修改 · 美学纲领与交互范式",
outputTags: ["设计.美学纲领与交互范式"],
});
expect(text).toContain("既有产物 · 继承修改");
expect(text).toContain("回头修改");
expect(text).toContain("warm");
expect(text).not.toContain("按 SKILL 与上方分区完成任务");
});
it("falls back to JSON inputs without segments", () => {
+75
View File
@@ -77,6 +77,24 @@ describe("context-fragment.v1", () => {
]);
});
it("does not surface empty 追问 导语 as sidecar assessment", () => {
const side = extractFragmentAskSidecar({
schema: "context-fragment.v1",
brief: "返现都市",
正文: { 美学纲领: { 体验内核: "被宠" } },
自评: {
维度: [{ 名: "交互范式", 分数: 9, 说明: "站位已清" }],
},
追问: {
导语: "为贴近你要的质感,还需确认:",
题目: [],
},
});
expect(side.questions).toHaveLength(0);
expect(side.assessment).toContain("交互范式 9/10");
expect(side.assessment).not.toContain("还需确认");
});
it("isUsableContextFragment requires non-empty 正文", () => {
expect(
isUsableContextFragment({
@@ -125,6 +143,63 @@ describe("context-order.v1", () => {
expect(segs[3].tier).toBe("dynamic");
});
it("accepts agents[] roster and maps gm → world-simulator", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
brief: "旁观+主世界层+转述",
agents: [
{
id: "auditor",
label: "表格管理",
enabled: true,
when: "every_turn",
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "设计.变量设计与更新规则", projection: "summary" },
],
},
{
id: "gm",
enabled: true,
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
{ order: 1, ref: "对话.历史", projection: "summary" },
],
},
{ id: "narrator", enabled: true, inserts: [] },
{ id: "perspective", enabled: false, inserts: [] },
{ id: "chance", enabled: false, when: "on_demand", inserts: [] },
],
})!;
expect(doc.play_slots?.auditor).not.toBe(false);
expect(doc.play_slots?.gm).toBe(true);
expect(doc.play_slots?.perspective).toBe(false);
expect(doc.play_slots?.chance).toBe(false);
expect(slotOrderForRef(doc, "auditor")?.label).toBe("表格管理");
expect(slotOrderForRef(doc, "world-simulator")?.inserts.some((i) => i.ref === "对话.历史")).toBe(
true,
);
expect(slotOrderForRef(doc, "role-decide")).toBeUndefined();
});
it("index projection keeps brief, mount and 稳变 without 正文", () => {
const text = projectFragmentContent(
JSON.stringify({
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "免疫即权力",
mount: ["world-simulator", "narrator"],
稳变: "stable",
正文: { 美学纲领: { 体验内核: "很长不应出现在 index 里的正文" } },
}),
"index",
);
expect(text).toContain("免疫即权力");
expect(text).toContain("挂载:world-simulator、narrator");
expect(text).toContain("稳变:stable");
expect(text).not.toContain("很长不应出现在 index 里的正文");
});
it("reorders flat list and merges into worker set JSON", () => {
const doc = parseContextOrder({
schema: "context-order.v1",
+192 -12
View File
@@ -290,12 +290,21 @@ describe("creation-flow", () => {
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.repeatable,
).toBe(true);
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.kind,
).toBe("prior-artifact");
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.kind,
).toBe("prior-artifact");
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.params?.[0]?.key,
).toBe("target");
expect(
catalog?.modules.find((m) => m.name === "具体实例")?.params?.[0]?.key,
).toBe("rule_id");
expect(
catalog?.modules.find((m) => m.name === "上下文投影排序")?.auto,
).toBe(true);
const gen = catalog?.modules.find((m) => m.name === "生成规则");
expect(gen?.when).toBeTruthy();
expect(gen?.when_not).toBeTruthy();
@@ -303,9 +312,12 @@ describe("creation-flow", () => {
const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("【能力");
expect(block).toContain("美学纲领与交互范式:");
expect(block).toContain("生成规则〔可反复〕");
expect(block).toContain("生成规则〔可反复〕〔先验产物〕");
expect(block).toContain("开场白与开场变量〔收口〕");
expect(block).toContain("上下文投影排序〔程序步〕");
expect(block).toContain("何时用");
expect(block).toContain("何时不用");
expect(block).toContain("规划产物字段");
expect(block).toContain("编排参数");
expect(block).not.toContain("设计.美学纲领与交互范式");
});
@@ -465,7 +477,8 @@ recipes:
expect(loaded.promptBody).toContain("已完成:");
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("已验收");
expect(loaded.promptBody).toContain("禁止再排一次执行");
expect(loaded.promptBody).toContain("禁止再排新建");
expect(loaded.promptBody).toContain("mode=revise");
expect(loaded.promptBody).toContain("生成规则");
});
@@ -506,6 +519,7 @@ recipes:
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("体验契约");
expect(loaded.promptBody).toContain("程序开场");
expect(loaded.promptBody).toContain("【本步参数】");
expect(loaded.worker.inputTags).toContain("创作.能力开场白");
});
@@ -606,24 +620,24 @@ recipes:
expect(view.steps[0]?.params?.target).toBe("怪物");
const catalog = await loadModuleCatalog("dialogue/world-simulator");
expect(
catalog?.modules.find((m) => m.name === "生成规则")?.params?.some(
(p) => p.key === "target" && p.required,
),
).toBe(true);
const rules = catalog?.modules.find((m) => m.name === "生成规则");
expect(rules?.kind).toBe("prior-artifact");
expect(rules?.params?.some((p) => p.key === "target")).toBe(true);
expect(validateCreationFlow(flow, catalog).ok).toBe(true);
const missing = parseCreationFlow(`{
"steps": [{ "name": "生成规则", "depends_on": [] }]
}`)!;
// 编排期允许空壳;必填 params 在确认开干时钉齐
// 先验产物:编排期允许空壳,确认开干也不拦
expect(validateCreationFlow(missing, catalog).ok).toBe(true);
const stepReady = validateStepReadyToRun(missing.steps[0]!, catalog);
expect(stepReady.ok).toBe(false);
expect(stepReady.errors.some((e) => e.includes("target"))).toBe(true);
expect(stepReady.ok).toBe(true);
const missingView = formatCreationFlowForUser(missing, catalog);
expect(missingView.steps[0]?.kind).toBe("prior-artifact");
expect(missingView.steps[0]?.paramsMissing).toBeUndefined();
const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("编排参数");
expect(block).toContain("规划产物字段");
expect(block).toContain("target");
});
@@ -648,12 +662,47 @@ recipes:
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【本步参数】");
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).not.toContain("勿再问「生成什么");
expect(loaded.promptBody).toContain("target: 怪物");
expect(loaded.promptBody).toContain("rule_id: monsters");
expect(loaded.worker.outputTags).toContain("设计.生成规则");
});
it("injects empty prior-artifact plan and still lets the step run", async () => {
const catalog = parseModuleCatalog(`
modules:
- name: 生成规则
declaration: 可执行规则
artifact: 设计.生成规则
kind: prior-artifact
params:
- key: target
label: 生成对象
required: true
`)!;
const empty = parseCreationFlow(`{
"steps": [{ "name": "生成规则", "depends_on": [] }]
}`)!;
expect(catalog.modules[0]?.kind).toBe("prior-artifact");
expect(validateStepReadyToRun(empty.steps[0]!, catalog).ok).toBe(true);
const loaded = await loadWorkerSkillWithContext(
"world-simulator",
"design-step",
undefined,
{
flowRaw: JSON.stringify({
steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }],
}),
currentStepName: "生成规则",
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).toContain("尚未规划本步具体写什么");
});
it("nextPendingStep respects deps and accepted by id", () => {
const flow = parseCreationFlow(`{
"status": "open",
@@ -730,4 +779,135 @@ recipes:
}`)!;
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true);
});
it("allows revising a non-repeatable skill and infers revises + id", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "实现机制", "depends_on": [] },
{ "name": "实现机制", "mode": "回头修改", "depends_on": ["实现机制"] }
]
}`)!;
expect(flow.steps[1]).toMatchObject({
id: "实现机制·改",
name: "实现机制",
mode: "revise",
revises: "实现机制",
});
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]);
expect(view.steps[1]?.mode).toBe("revise");
expect(view.steps[1]?.occurrence).toBeUndefined();
expect(view.steps[1]?.runState).toBe("current");
});
it("still rejects two fresh copies of a non-repeatable skill", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "实现机制", "depends_on": [] },
{ "id": "实现机制#2", "name": "实现机制", "depends_on": ["实现机制"] }
]
}`)!;
const result = validateCreationFlow(flow, sampleCatalog);
expect(result.ok).toBe(false);
expect(result.errors.some((e) => e.includes("repeatable"))).toBe(true);
});
it("treats a second generation-rules step as fresh, not revise", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "id": "生成规则·怪物", "name": "生成规则", "params": { "target": "怪物" }, "depends_on": [] },
{ "id": "生成规则·NPC", "name": "生成规则", "params": { "target": "NPC" }, "depends_on": ["生成规则·怪物"] }
]
}`)!;
expect(flow.steps[1]?.mode).toBeUndefined();
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
});
it("revise of generation-rules inherits params from the origin step", () => {
const flow = parseCreationFlow(`{
"steps": [
{
"id": "生成规则·怪物",
"name": "生成规则",
"params": { "target": "怪物", "rule_id": "monsters" },
"depends_on": []
},
{
"id": "生成规则·怪物·改",
"name": "生成规则",
"mode": "revise",
"revises": "生成规则·怪物",
"depends_on": ["生成规则·怪物"]
}
]
}`)!;
expect(flow.steps[1]?.params).toEqual({
target: "怪物",
rule_id: "monsters",
});
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
});
it("nextPendingStep prefers a ready revise over a later fresh step", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["美学纲领与交互范式"]
}
]
}`)!;
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise");
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe(
"美学纲领与交互范式·改",
);
});
it("injects inherit draft and skips opening on a revise design-step", async () => {
const flowRaw = JSON.stringify({
status: "open",
steps: [
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
{
id: "美学纲领与交互范式·改",
name: "美学纲领与交互范式",
mode: "revise",
revises: "美学纲领与交互范式",
depends_on: ["美学纲领与交互范式"],
},
],
});
const binding = await resolveDesignStepBinding({
skillPackRoot: "dialogue/world-simulator",
flowRaw,
currentStepName: "美学纲领与交互范式·改",
acceptedStepNames: ["美学纲领与交互范式"],
});
expect(binding?.opening).toBeNull();
expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式");
const loaded = await loadWorkerSkillWithContext(
"world-simulator",
"design-step",
undefined,
{
flowRaw,
currentStepName: "美学纲领与交互范式·改",
acceptedStepNames: ["美学纲领与交互范式"],
},
);
expect(loaded.promptBody).toContain("【回头修改】");
expect(loaded.promptBody).not.toContain("程序开场");
expect(loaded.worker.name).toContain("回头修改");
expect(loaded.worker.inputTags).toContain("设计.美学纲领与交互范式");
expect(
loaded.worker.contextSegments?.some((s) => s.id === "inherit-existing"),
).toBe(true);
});
});
+31 -2
View File
@@ -17,7 +17,7 @@ describe("resolveAcceptanceModeForWorker", () => {
expect(mode).toBe("user_confirmed");
});
it("maps review → user_confirmed and continue → no_confirmation", () => {
it("maps review → user_confirmed and continue → no_confirmation in design", () => {
const bb = new Blackboard();
const yaml = JSON.stringify({
version: 1,
@@ -32,7 +32,7 @@ describe("resolveAcceptanceModeForWorker", () => {
slots: {
...createSession().slots,
designInstanceReady: true,
uiLifecycleStage: "play",
uiLifecycleStage: "design",
},
};
@@ -51,6 +51,34 @@ describe("resolveAcceptanceModeForWorker", () => {
}),
).toBe("user_confirmed");
});
it("play ignores worker-set review and auto-continues", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.worker集",
content: JSON.stringify({
version: 1,
workers: [{ ref: "narrator", acceptance: "review" }],
}),
source: "test",
});
const session = {
...createSession(),
slots: {
...createSession().slots,
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
},
};
expect(
resolveAcceptanceModeForWorker({
session,
blackboard: bb,
workerId: "narrator",
}),
).toBe("no_confirmation");
});
});
describe("buildDeclaredWorkerSkill", () => {
@@ -92,5 +120,6 @@ describe("buildDeclaredWorkerSkill", () => {
expect(promptBody).toContain("残酷但不虐主");
expect(promptBody).toContain("主角免疫");
expect(promptBody).toContain("声明驱动");
expect(promptBody).toContain("500~2000");
});
});
+6
View File
@@ -45,5 +45,11 @@ describe("display-labels", () => {
const opening = reviewComposerCopy("opening-generator");
expect(opening.acceptLabel).toBe("选定此开场");
expect(opening.kicker).toBe("待选定");
const setup = reviewComposerCopy("design-step", {
outputTags: ["设计.开场白与开场变量"],
});
expect(setup.acceptLabel).toBe("选定此开场");
expect(setup.kind).toBe("opening");
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
createMockToolCall,
MockLlmProvider,
} from "../src/llm/client.js";
import { createLocalLlmDriver } from "../src/runtime/driver.js";
describe("local LLM driver", () => {
it("continues while tools return results, then stops on text", async () => {
const llm = new MockLlmProvider([
createMockToolCall("lookup", { q: "a" }, "c1"),
"done",
]);
const driver = createLocalLlmDriver(llm);
const seen: string[] = [];
const run = await driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "lookup",
description: "lookup",
parameters: { type: "object", properties: {} },
},
},
],
handleStep: (calls) => {
seen.push(...calls.map((c) => c.name));
return {
kind: "continue",
results: calls.map((c) => ({ callId: c.id, content: "ok" })),
};
},
});
expect(seen).toEqual(["lookup"]);
expect(run.iterations).toBe(2);
expect(run.stop).toEqual({ kind: "text", content: "done" });
});
it("stops when handleStep concludes, without another model call", async () => {
const llm = new MockLlmProvider([
createMockToolCall("run_worker", { workerId: "x" }, "t1"),
"should-not-be-used",
]);
const driver = createLocalLlmDriver(llm);
const run = await driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "run_worker",
description: "run",
parameters: { type: "object", properties: {} },
},
},
],
handleStep: (calls) => ({ kind: "conclude", call: calls[0]! }),
});
expect(run.iterations).toBe(1);
expect(run.stop.kind).toBe("conclude");
if (run.stop.kind === "conclude") {
expect(run.stop.call.name).toBe("run_worker");
}
});
it("throws when the iteration cap is hit", async () => {
const llm = new MockLlmProvider([
createMockToolCall("ping", {}, "p1"),
]);
const driver = createLocalLlmDriver(llm);
await expect(
driver.run({
system: "sys",
messages: [{ role: "user", content: "go" }],
tools: [
{
type: "function",
function: {
name: "ping",
description: "ping",
parameters: { type: "object", properties: {} },
},
},
],
maxIterations: 2,
handleStep: (calls) => ({
kind: "continue",
results: calls.map((c) => ({ callId: c.id, content: "pong" })),
}),
}),
).rejects.toThrow(/exceeded 2 iterations/);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
closeCreationFlowRaw,
isOpeningSealArtifact,
parseOpeningSealPayload,
openingVariablesToTableDoc,
} from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({
schema: "context-fragment.v1",
技能: "开场白与开场变量",
brief: "宿舍缺水",
mount: ["narrator"],
正文: {
开场白全文: "你在宿舍醒来,瓶里只剩一口水。",
开场白候选: [
"你在宿舍醒来,瓶里只剩一口水。",
"门外有人砸门。你还没想好要不要开。",
],
开场变量: [{ 名: "口粮", 值: 1, 依据: "开场只剩一口水" }],
},
开放问题: [],
});
describe("opening-seal", () => {
it("detects opening-setup and opening-generator artifacts", () => {
expect(
isOpeningSealArtifact({
workerId: "design-step",
outputTags: ["设计.开场白与开场变量"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "opening-generator",
outputTags: ["输出.开场白"],
}),
).toBe(true);
expect(
isOpeningSealArtifact({
workerId: "design-step",
outputTags: ["设计.美学纲领与交互范式"],
}),
).toBe(false);
});
it("parses 1+ opening candidates and selects by index", () => {
const first = parseOpeningSealPayload(fragment, 0);
expect(first?.candidates).toHaveLength(2);
expect(first?.selectedText).toContain("宿舍醒来");
const second = parseOpeningSealPayload(fragment, 1);
expect(second?.selectedText).toContain("砸门");
expect(second?.variables).toEqual([
{ name: "口粮", value: 1, note: "开场只剩一口水" },
]);
});
it("builds table rows from opening variables", () => {
const payload = parseOpeningSealPayload(fragment, 0);
const doc = openingVariablesToTableDoc(payload!.variables);
expect(doc.rows[0]?.key).toBe("口粮");
expect(doc.rows[0]?.value).toBe(1);
});
it("closes an open creation flow", () => {
const raw = JSON.stringify({
version: 1,
status: "open",
steps: [{ id: "开场白与开场变量", name: "开场白与开场变量", depends_on: [] }],
});
const closed = JSON.parse(closeCreationFlowRaw(raw)!);
expect(closed.status).toBe("closed");
});
});
+227 -3
View File
@@ -209,6 +209,72 @@ describe("phase machine", () => {
]);
});
it("later revision notes accumulate instead of replacing earlier ones", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.正文组成"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
session = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "不如原本的上中下样式" },
}).session;
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
pendingArtifactId: artifact.id,
};
const second = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "状态很短,放到上面就行了" },
});
expect(second.session.slots["用户.修订说明"]).toContain("不如原本的上中下样式");
expect(second.session.slots["用户.修订说明"]).toContain("状态很短,放到上面就行了");
});
it("accepting a design-step artifact clears stale revision notes", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
outputTags: ["设计.监控栏"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
"用户.修订说明": "上一步的意见",
revisionInstruction: "上一步的意见",
"用户.worker答复": "监控对象是剩余食物",
},
};
session = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
}).session;
expect(session.slots["用户.修订说明"]).toBeUndefined();
expect(session.slots.revisionInstruction).toBeUndefined();
expect(session.slots["用户.worker答复"]).toBeUndefined();
expect(session.slots["用户.需求"]).toContain("监控对象是剩余食物");
expect(session.slots["用户.需求"]).toContain("上一步的意见");
});
it("failed revision rerun falls back to reviewing the previous artifact", () => {
let session = createSession("default");
const artifact = {
@@ -281,7 +347,35 @@ describe("phase machine", () => {
]);
});
it("accepting design-step enters next_intent instead of invoking main agent", () => {
it("accepting design-step worker set proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "细化终稿",
outputTags: ["设计.worker集"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.slots.designInstanceReady).toBe(true);
expect(accepted.session.phase).toBe("running");
expect(accepted.session.waitingReason).toBeUndefined();
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("accepting design-step proposes next step instead of invoking main agent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
@@ -300,12 +394,22 @@ describe("phase machine", () => {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("next_intent");
expect(accepted.session.phase).toBe("running");
expect(accepted.effects).toEqual([{ type: "propose_next_creation_step" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
const continued = applyEvent(accepted.session, {
it("legacy next_intent still proposes next step and records intent", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "next_intent", afterWorkerId: "design-step" },
};
const continued = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "接下来写怪物生成规则" },
});
@@ -317,4 +421,124 @@ describe("phase machine", () => {
{ type: "propose_next_creation_step" },
]);
});
it("accepting opening-setup seals creation instead of next_intent", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "开场白与开场变量",
outputTags: ["设计.开场白与开场变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.session.waitingReason).toMatchObject({
kind: "input",
});
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
expect(
accepted.effects.some((e) => e.type === "invoke_main_agent"),
).toBe(false);
});
it("accepting opening-generator also seals creation", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "opening-generator",
outputTags: ["输出.开场白", "运行.初始变量"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.session.waitingReason?.kind).toBe("input");
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
});
it("sealed creation still blocks input until play", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: { creationSealedByOpening: true, designInstanceReady: true },
};
const blocked = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
});
expect(blocked.effects).toEqual([
{ type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" },
]);
expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false);
});
it("play input starts a play turn even after opening seal", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "input" },
slots: {
creationSealedByOpening: true,
designInstanceReady: true,
uiLifecycleStage: "play",
},
};
const play = applyEvent(session, {
type: "user_submitted_input",
payload: { text: "我买金首饰" },
});
expect(play.session.phase).toBe("running");
expect(play.effects).toEqual([{ type: "run_play_turn" }]);
});
it("accepting a play worker continues the play pipeline", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "narrator",
outputTags: ["输出.用户展示"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
},
};
const accepted = applyEvent(session, {
type: "user_accepted_artifact",
payload: { artifactId: artifact.id },
});
expect(accepted.effects).toEqual([{ type: "continue_play_turn" }]);
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
expect(accepted.effects.some((e) => e.type === "propose_next_creation_step")).toBe(
false,
);
});
});
+80
View File
@@ -5,6 +5,8 @@ import {
MockLlmProvider,
} from "../src/llm/client.js";
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
import { createSession } from "../src/runtime/phase-machine.js";
import { Blackboard } from "../src/blackboard/blackboard.js";
describe("phase runtime", () => {
it("auto-loads default orchestrator and awaits first user input", async () => {
@@ -47,4 +49,82 @@ describe("phase runtime", () => {
true,
);
});
it("play turn runs declared pipeline without review and waits for input", async () => {
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = { kind: "input" };
session.slots = {
designInstanceReady: true,
uiLifecycleStage: "play",
playLayerActive: true,
startupCompleted: true,
};
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.worker集",
content: JSON.stringify({
version: 1,
play_slots: {
auditor: false,
gm: true,
narrator: true,
perspective: false,
},
workers: [
{ ref: "world-simulator", acceptance: "continue" },
{ ref: "narrator", acceptance: "review" },
],
}),
source: "test",
});
const runtime = new PhaseRuntime({
autoStubWorker: true,
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.submitInput("我网购一个金首饰试试");
expect(runtime.getSession().waitingReason?.kind).toBe("input");
expect(runtime.getSession().artifacts.map((a) => a.workerId)).toEqual([
"world-simulator",
"narrator",
]);
expect(runtime.getSession().artifacts.every((a) => a.status === "accepted")).toBe(
true,
);
});
it("retryStuckRun restores the in-flight worker snapshot and reruns", async () => {
const runtime = new PhaseRuntime({ autoStubWorker: true });
await runtime.start();
await runtime.submitInput("网恋对象对话");
await runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "编排创作流程",
workerId: "design-flow",
requiresApproval: false,
}),
);
const snap = runtime.getLastWorkerRunSnapshot();
expect(snap?.workerId).toBe("design-flow");
const artifactsBefore = runtime.getSession().artifacts.length;
const session = runtime.getSession();
session.phase = "running";
session.waitingReason = undefined;
session.currentWorkerId = "design-flow";
await runtime.retryStuckRun();
expect(runtime.getSession().artifacts.length).toBeGreaterThan(artifactsBefore);
expect(
runtime.getSession().artifacts.filter((a) => a.workerId === "design-flow").length,
).toBeGreaterThanOrEqual(2);
});
it("retryStuckRun throws when nothing is running", async () => {
const runtime = new PhaseRuntime({ autoStubWorker: true });
await runtime.start();
await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行");
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import { assemblePlayWorkerMessages } from "../src/preset/play-frame.js";
import { assemblePresetMessages } from "../src/preset/assembler.js";
import type { WorldInfoPack } from "../src/preset/world-info-pack.js";
const pack: WorldInfoPack = {
worldBookBefore: [{ id: "lore", name: "舞台", content: "古镇夜雨" }],
history: "用户:昨天来过",
worldBookAfter: [{ id: "vars", name: "变量", content: "HP 3" }],
turn: "我推门",
postTurn: [{ id: "tail", name: "尾注", content: "勿剧透" }],
};
describe("assemblePlayWorkerMessages", () => {
it("walks extended prompt_order: ST pre, our before, history, our after, turn, ST post, postTurn", () => {
const report = importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
});
const messages = assemblePlayWorkerMessages({
systemPrompt: "SKILL",
preset: report.preset,
pack,
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", "SKILL"],
["system", "MAIN"],
["system", "### 舞台\n\n古镇夜雨"],
["system", "用户:昨天来过"],
["system", "### 变量\n\nHP 3"],
["user", "我推门"],
["system", "PHI"],
["system", "### 尾注\n\n勿剧透"],
]);
});
it("does not fill ST charDescription even if resolver returns content", () => {
const report = importSillyTavernPreset({
prompts: [
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
],
},
],
});
const messages = assemblePresetMessages(report.preset, (id) => {
if (id === "charDescription") return "CARD";
if (id === "chatHistory") return "HIST";
return null;
});
expect(messages.map((m) => m.content)).toEqual(["HIST"]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
SLOT_PLAY_LAYER_ACTIVE,
SLOT_PLAY_TURN_QUEUE,
isPlayLayerActive,
readPlayTurnQueue,
withPlayTurnQueue,
} from "../src/skills/play-turn.js";
describe("play-turn slots", () => {
it("reads a trimmed queue and ignores junk", () => {
expect(readPlayTurnQueue(undefined)).toEqual([]);
expect(
readPlayTurnQueue({ [SLOT_PLAY_TURN_QUEUE]: [" auditor ", "", 3, "gm"] }),
).toEqual(["auditor", "gm"]);
});
it("writes queue or clears when empty", () => {
const withIds = withPlayTurnQueue({ a: 1 }, ["gm", "narrator"]);
expect(withIds[SLOT_PLAY_TURN_QUEUE]).toEqual(["gm", "narrator"]);
expect(withPlayTurnQueue(withIds, []).playTurnQueue).toBeUndefined();
});
it("detects play layer flag", () => {
expect(isPlayLayerActive(undefined)).toBe(false);
expect(isPlayLayerActive({ [SLOT_PLAY_LAYER_ACTIVE]: true })).toBe(true);
});
});
+25
View File
@@ -51,6 +51,31 @@ describe("present.v1", () => {
expect(p.shell).toBe("chapter_reader");
expect(p.blocks.body).toBe("hello");
});
it("strips 输出.用户展示 heading from plain body", () => {
const view = parsePresentPacket("### 输出.用户展示\n\n叮!账户来了。");
expect(view.packet.blocks.body).toBe("叮!账户来了。");
expect(String(view.packet.blocks.body)).not.toContain("用户展示");
});
it("strips heading then parses present JSON", () => {
const view = parsePresentPacket(
'### 输出.用户展示\n\n{"schema":"present.v1","shell":"prose","blocks":{"body":"场面很长"}}',
);
expect(view.fallbackPlain).toBe(false);
expect(view.packet.blocks.body).toBe("场面很长");
});
it("strips heading inside blocks.body", () => {
const view = parsePresentPacket(
JSON.stringify({
schema: "present.v1",
shell: "prose",
blocks: { body: "### 输出.用户展示\n正文" },
}),
);
expect(view.packet.blocks.body).toBe("正文");
});
});
describe("shell adaptation from reply-format", () => {
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { importSillyTavernPreset } from "../src/preset/importer.js";
import {
assemblePresetProbe,
DEFAULT_PROBE_CONTEXT,
PROBE_SYSTEM_PROMPT,
runPresetProbe,
} from "../src/preset/probe.js";
function samplePreset() {
return importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Desc",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "History",
role: "system",
marker: true,
},
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
}).preset;
}
describe("assemblePresetProbe", () => {
it("fills the sandwich with sample lore, history, and the current turn", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "我推开门。",
});
expect(messages.map((m) => [m.role, m.content])).toEqual([
["system", PROBE_SYSTEM_PROMPT],
["system", "MAIN"],
["system", `### 试跑设定\n\n${DEFAULT_PROBE_CONTEXT.loreBefore}`],
["system", DEFAULT_PROBE_CONTEXT.history],
["system", `### 本轮状态\n\n${DEFAULT_PROBE_CONTEXT.loreAfter}`],
["user", "我推开门。"],
["system", "PHI"],
]);
});
it("omits empty optional bags", () => {
const messages = assemblePresetProbe(samplePreset(), {
message: "你好",
loreBefore: "",
history: "",
loreAfter: "",
postTurn: "",
});
expect(messages.map((m) => m.content)).toEqual([
PROBE_SYSTEM_PROMPT,
"MAIN",
"你好",
"PHI",
]);
});
});
describe("runPresetProbe", () => {
it("returns assembled messages without calling a model", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
});
expect(result.completed).toBe(false);
expect(result.reply).toBeNull();
expect(result.messages.some((m) => m.content === "我推开门。")).toBe(true);
});
it("returns the model reply when complete is provided", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
complete: async () => ({ content: "请进。", reasoning: "短回复" }),
});
expect(result.completed).toBe(true);
expect(result.reply).toBe("请进。");
expect(result.reasoning).toBe("短回复");
});
it("keeps messages when generate fails", async () => {
const result = await runPresetProbe({
preset: samplePreset(),
input: { message: "我推开门。" },
complete: async () => {
throw new Error("upstream down");
},
});
expect(result.completed).toBe(false);
expect(result.error).toBe("upstream down");
expect(result.messages.length).toBeGreaterThan(0);
});
it("rejects an empty turn", async () => {
await expect(
runPresetProbe({
preset: samplePreset(),
input: { message: " " },
}),
).rejects.toThrow("缺少本轮输入");
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type {
ChatMessage,
CompleteResult,
LlmProvider,
} from "../src/llm/client.js";
import { wrapLlmForSession } from "../src/llm/preset-wrapper.js";
import type { LlmTrackingContext } from "../src/llm/token-tracker.js";
import type { PresetPackage } from "../src/types/preset.js";
const samplePreset: PresetPackage = {
id: "p1",
name: "test-preset",
source: "native",
prompts: [
{
id: "sys",
name: "系统",
enabled: true,
role: "system",
content: "PRESET_RULES",
marker: false,
sourceIdentifier: "sys",
},
],
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
generation: { temperature: 0.91, maxOutputTokens: 1234 },
unsupported: [],
importedAt: "2026-01-01T00:00:00.000Z",
};
function recordingProvider(): {
provider: LlmProvider;
received: Array<{ messages: ChatMessage[]; generation?: unknown }>;
} {
const received: Array<{ messages: ChatMessage[]; generation?: unknown }> = [];
const provider: LlmProvider = {
async complete(messages, options): Promise<CompleteResult> {
received.push({ messages, generation: options?.generation });
return { content: "ok", model: "rec-model" };
},
async completeStream(messages, options, callbacks) {
received.push({ messages, generation: options?.generation });
callbacks?.onContentDelta?.("ok");
return { content: "ok", model: "rec-model" };
},
async completeWithTools(messages, options) {
received.push({ messages, generation: options.generation });
return { content: "ok", toolCalls: [], model: "rec-model" };
},
};
return { provider, received };
}
describe("wrapLlmForSession", () => {
it("context trace matches the exact messages and generation sent to the inner LLM", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
await llm.complete(
[{ role: "user", content: "TASK" }],
{ caller: "worker:design-step" },
);
expect(received).toHaveLength(1);
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(received[0].generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
});
expect(ctx.pendingContextTrace?.messages).toEqual([
{ role: "user", content: "TASK" },
]);
expect(ctx.pendingContextTrace?.generation).toMatchObject({
temperature: 0.91,
maxOutputTokens: 1234,
});
expect(ctx.pendingContextTrace?.caller).toBe("worker:design-step");
expect(ctx.pendingContextTrace?.model).toBe("rec-model");
});
it("completeStream also records the merged request", async () => {
const { provider, received } = recordingProvider();
const ctx: LlmTrackingContext = {};
const llm = wrapLlmForSession(provider, { current: ctx }, () => samplePreset);
const chunks: string[] = [];
await llm.completeStream!(
[{ role: "user", content: "TASK" }],
{ caller: "worker:stream" },
{ onContentDelta: (d) => chunks.push(d) },
);
expect(chunks.join("")).toBe("ok");
expect(received[0].messages).toEqual([{ role: "user", content: "TASK" }]);
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
});
});
+59 -2
View File
@@ -52,16 +52,73 @@ const samplePreset = {
describe("importSillyTavernPreset", () => {
it("picks prompt_order with most enabled content", () => {
const report = importSillyTavernPreset(samplePreset, { name: "test" });
expect(report.enabledCount).toBe(3);
expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([
"custom-a",
"custom-b",
"main",
"worldBookBefore",
"chatHistory",
"worldBookAfter",
"currentTurn",
"postTurn",
]);
expect(report.enabledCount).toBe(8);
expect(report.preset.generation.temperature).toBe(1);
expect(report.preset.generation.maxOutputTokens).toBe(60000);
expect(report.warnings.some((w) => w.includes("prompt_order"))).toBe(true);
});
it("inserts app markers around chatHistory and leaves ST card/WI empty", () => {
const report = importSillyTavernPreset({
prompts: [
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
{
identifier: "charDescription",
name: "Char Description",
role: "system",
marker: true,
},
{
identifier: "chatHistory",
name: "Chat History",
role: "system",
marker: true,
},
{
identifier: "jailbreak",
name: "Post-History",
role: "system",
content: "PHI",
},
],
prompt_order: [
{
character_id: 1,
order: [
{ identifier: "main", enabled: true },
{ identifier: "charDescription", enabled: true },
{ identifier: "chatHistory", enabled: true },
{ identifier: "jailbreak", enabled: true },
],
},
],
});
expect(report.preset.promptOrder.map((o) => o.promptId)).toEqual([
"main",
"charDescription",
"worldBookBefore",
"chatHistory",
"worldBookAfter",
"currentTurn",
"jailbreak",
"postTurn",
]);
const messages = assemblePresetMessages(report.preset);
expect(messages).toEqual([
{ role: "system", content: "MAIN" },
{ role: "system", content: "PHI" },
]);
});
});
describe("assemblePresetMessages", () => {
@@ -79,7 +136,7 @@ describe("listEnabledPresetEntries", () => {
it("lists enabled order with inject flags", () => {
const report = importSillyTavernPreset(samplePreset);
const entries = listEnabledPresetEntries(report.preset);
expect(entries.length).toBe(3);
expect(entries.length).toBe(8);
expect(countInjectingEntries(entries)).toBe(2);
expect(entries.filter((e) => e.willInject).map((e) => e.content)).toEqual([
"instruction A",
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { MockLlmProvider } from "../src/llm/client.js";
import {
isAbortError,
runWithAbortSignal,
throwIfAborted,
} from "../src/llm/run-abort.js";
describe("run abort", () => {
it("isAbortError detects AbortError name", () => {
const err = new Error("This operation was aborted");
err.name = "AbortError";
expect(isAbortError(err)).toBe(true);
expect(isAbortError(new Error("LLM request failed"))).toBe(false);
});
it("throwIfAborted raises AbortError when the signal is aborted", () => {
const ac = new AbortController();
ac.abort();
expect(() => throwIfAborted(ac.signal)).toThrowError(/aborted/i);
});
it("mock LLM respects the active abort signal", async () => {
const llm = new MockLlmProvider(["ok"]);
const ac = new AbortController();
ac.abort();
await expect(
runWithAbortSignal(ac.signal, () => llm.complete([{ role: "user", content: "x" }])),
).rejects.toMatchObject({ name: "AbortError" });
});
});
+9 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
filterBlackboardForInstance,
isRunPhaseBlackboardTag,
materializeInstanceSnapshotPayload,
} from "../src/book/snapshot-filters.js";
@@ -12,6 +11,10 @@ describe("snapshot-filters", () => {
expect(isRunPhaseBlackboardTag("角色.A.设定")).toBe(false);
expect(isRunPhaseBlackboardTag("博弈.规则.草稿")).toBe(false);
expect(isRunPhaseBlackboardTag("运行.事件流")).toBe(true);
expect(isRunPhaseBlackboardTag("输出.开场白")).toBe(false);
expect(isRunPhaseBlackboardTag("运行.初始变量")).toBe(false);
expect(isRunPhaseBlackboardTag("变量.当前")).toBe(false);
expect(isRunPhaseBlackboardTag("对话.历史")).toBe(true);
expect(isRunPhaseBlackboardTag("角色.A.思考")).toBe(true);
expect(isRunPhaseBlackboardTag("世界.当前状态")).toBe(true);
});
@@ -19,6 +22,8 @@ describe("snapshot-filters", () => {
it("materializeInstanceSnapshotPayload removes run blackboard items", () => {
const session = createSession("default");
session.slots.startupCompleted = true;
session.slots.playLayerActive = true;
session.slots.playTurnQueue = ["narrator"];
session.artifacts = [
{
id: "a1",
@@ -44,6 +49,7 @@ describe("snapshot-filters", () => {
{ id: "1", tag: "情境.实验.设定", content: "x", source: "worker", createdAt: "", updatedAt: "" },
{ id: "2", tag: "运行.事件流", content: "y", source: "worker", createdAt: "", updatedAt: "" },
{ id: "3", tag: "角色.A.设定", content: "z", source: "worker", createdAt: "", updatedAt: "" },
{ id: "4", tag: "对话.历史", content: "## 用户\n\n买金", source: "runtime", createdAt: "", updatedAt: "" },
],
});
@@ -53,6 +59,7 @@ describe("snapshot-filters", () => {
]);
expect(runtimeSession.artifacts).toHaveLength(1);
expect(runtimeSession.artifacts[0].workerId).toBe("setup-scenario");
expect(filterBlackboardForInstance).toBeDefined();
expect(runtimeSession.slots.playLayerActive).toBeUndefined();
expect(runtimeSession.slots.playTurnQueue).toBeUndefined();
});
});
+9
View File
@@ -98,5 +98,14 @@ describe("worker-declaration", () => {
},
};
expect(inferLifecycleStage(s2)).toBe("play");
const s3 = {
...sessionWithAcceptedWorkerSet(),
slots: {
...sessionWithAcceptedWorkerSet().slots,
playLayerActive: true,
},
};
expect(inferLifecycleStage(s3)).toBe("play");
});
});
+52 -5
View File
@@ -191,7 +191,7 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("特权与惊惶");
});
it("does not accept a fragment without 正文; keeps 追问 for the LLM loop", () => {
it("keeps incomplete fragment (no 正文) for review; 追问 hangs as sidecar", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
@@ -210,12 +210,12 @@ describe("sanitizeWorkerSetOutputs", () => {
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("还差站位");
expect(result.askUser?.length).toBeGreaterThan(0);
expect(result.askUser?.[0]?.prompt).toContain("你代入吗");
});
it("asks to continue the loop when fragment JSON is unusable", () => {
it("keeps truncated fragment JSON as a reviewable draft", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
@@ -226,7 +226,54 @@ describe("sanitizeWorkerSetOutputs", () => {
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.askUser?.[0]?.prompt).toContain("还没写出可验收的产物");
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("孤立免疫");
expect(result.askUser).toBeUndefined();
});
it("canonicalizes context-order agents[] into slots on the artifact tag", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.上下文投影排序": {
schema: "context-order.v1",
brief: "主世界层",
agents: [
{
id: "gm",
enabled: true,
inserts: [
{ order: 0, ref: "worker.persona", projection: "fixed" },
],
},
],
},
},
summary: "ok",
}),
["设计.上下文投影排序"],
);
const doc = JSON.parse(result.outputs["设计.上下文投影排序"]!);
expect(doc.slots[0].ref).toBe("world-simulator");
expect(doc.play_slots.gm).toBe(true);
});
it("keeps a whole-response truncated JSON as the artifact draft", () => {
const raw =
'{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":[';
const result = parseWorkerResponseForTest(raw, ["设计.上下文投影排序"]);
expect(result.outputs["设计.上下文投影排序"]).toContain("context-order.v1");
expect(result.askUser).toBeUndefined();
});
it("puts full 输出.用户展示 into preview without tag fence or 4000 cap", () => {
const body = "场面".repeat(2100);
const result = sanitizeWorkerSetOutputs({
outputs: { "输出.用户展示": body },
summary: "ok",
preview: "旧",
});
expect(result.preview).toBe(body);
expect(result.preview).not.toContain("### ");
expect(result.preview.length).toBeGreaterThan(4000);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { Blackboard } from "../src/blackboard/blackboard.js";
import { DIALOGUE_HISTORY_TAG } from "../src/skills/dialogue-history.js";
import {
CURRENT_TURN_TAG,
resolveWorldInfoMarker,
worldInfoPackFromSegments,
} from "../src/preset/world-info-pack.js";
import {
CURRENT_TURN,
WORLD_BOOK_AFTER,
WORLD_BOOK_BEFORE,
} from "../src/preset/markers.js";
import type { ContextSegmentDef } from "../src/skills/context-segments.js";
describe("worldInfoPackFromSegments", () => {
it("splits lore / history / after-history / turn by the two anchors", () => {
const bb = new Blackboard();
const segments: ContextSegmentDef[] = [
{
id: "stage",
tier: "static",
tags: [],
inline: "古镇夜雨",
label: "## 舞台骨架",
},
{
id: "hist",
tier: "dynamic",
tags: [DIALOGUE_HISTORY_TAG],
},
{
id: "vars",
tier: "dynamic",
tags: ["变量.当前"],
label: "## 变量",
},
{
id: "turn",
tier: "dynamic",
tags: [CURRENT_TURN_TAG],
},
{
id: "note",
tier: "dynamic",
tags: [],
inline: "尾注",
label: "## 本轮后",
},
];
const pack = worldInfoPackFromSegments({
segments,
inputs: {
[DIALOGUE_HISTORY_TAG]: "用户:你好",
"变量.当前": "HP 3",
[CURRENT_TURN_TAG]: "我推门进去",
},
blackboard: bb,
});
expect(pack.worldBookBefore.map((e) => e.content)).toEqual(["古镇夜雨"]);
expect(pack.history).toContain("你好");
expect(pack.worldBookAfter.map((e) => e.content)).toEqual(["HP 3"]);
expect(pack.turn).toBe("我推门进去");
expect(pack.postTurn.map((e) => e.content)).toEqual(["尾注"]);
expect(resolveWorldInfoMarker(pack, WORLD_BOOK_BEFORE)).toContain("古镇夜雨");
expect(resolveWorldInfoMarker(pack, WORLD_BOOK_AFTER)).toContain("HP 3");
expect(resolveWorldInfoMarker(pack, CURRENT_TURN)).toBe("我推门进去");
expect(resolveWorldInfoMarker(pack, "charDescription")).toBeNull();
});
});
+514 -73
View File
@@ -12,6 +12,7 @@ import {
parsePresentDoc,
presentFromPlain,
renderPresentShellHtml,
stripPresentSourceFences,
} from "./present-shells.js";
const HIDE_KINDS = new Set(["worker_stub"]);
@@ -57,6 +58,10 @@ function esc(s) {
.replace(/>/g, "&gt;");
}
function escAttr(s) {
return esc(s).replace(/"/g, "&quot;");
}
function renderThinkingBlock(thinking, { open = false } = {}) {
const text = (thinking ?? "").trim();
if (!text) return "";
@@ -66,23 +71,39 @@ function renderThinkingBlock(thinking, { open = false } = {}) {
</details>`;
}
function isPlayView(view) {
return Boolean(view?.playLayerActive || view?.lifecycleStage === "play");
}
function renderLiveStreamBody(live, view) {
const thinking = (live?.thinking || view?.agentThinking || "").trim();
const output = (live?.output ?? "").trim();
const showOutput =
Boolean(output) && (view?.lifecycleStage ?? document.body.dataset.lifecycle) === "play";
const parts = [];
if (thinking) {
parts.push(
`<section class="msg-live-section"><header>思考</header><pre class="msg-live-pre">${esc(thinking)}</pre></section>`,
);
}
if (showOutput) {
if (output) {
parts.push(
`<section class="msg-live-section"><header>输出</header><pre class="msg-live-pre">${esc(output)}</pre></section>`,
);
}
return parts.length ? parts.join("") : "…";
return parts.length
? parts.join("")
: `<p class="msg-live-empty">正在生成,尚未收到内容</p>`;
}
function renderRetryRunButton() {
return `<button type="button" class="msg-action msg-action-primary" data-act="retry_run" title="停止当前生成并重试">↻</button>`;
}
function wireRetryRun(root, handlers) {
root?.querySelector("[data-act=retry_run]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
handlers.onRetryRun?.();
});
}
function fmtTime(iso) {
@@ -141,13 +162,19 @@ function hideMsgMenu() {
const menu = document.getElementById("msg-action-menu");
if (menu) menu.hidden = true;
msgMenuState.messageId = null;
msgMenuState.contextMessageId = null;
msgMenuState.rollbackMessageId = null;
msgMenuState.rollbackMode = "keep";
msgMenuState.restoreDraft = "";
msgMenuState.card = null;
}
const msgMenuState = {
messageId: null,
contextMessageId: null,
rollbackMessageId: null,
rollbackMode: "keep",
restoreDraft: "",
card: null,
handlers: null,
};
@@ -155,25 +182,38 @@ const msgMenuState = {
function showMsgMenu(card, x, y) {
const menu = document.getElementById("msg-action-menu");
if (!menu || !card) return;
const isReview = card.dataset.review === "1";
const isReadOnly = card.dataset.readOnly === "1";
const canEdit = !isReview && card.dataset.canEdit === "1";
const canDelete = !isReview && !isReadOnly && Boolean(card.dataset.messageId);
const canRollback = !isReview && !isReadOnly && Boolean(card.dataset.rollbackMessageId);
const hasContext = card.dataset.hasContext === "1";
const pack = card.closest(".coord-pack");
const rollbackHost = pack || card;
// 上下文按单次请求(这条消息)区分,不能回落到整包里的另一条
const contextTarget = card.dataset.hasContext === "1" ? card : null;
const isReview = card.dataset.review === "1" || pack?.dataset.review === "1";
const isReadOnly = card.dataset.readOnly === "1" || pack?.dataset.readOnly === "1";
const canEdit = !isReview && !pack && card.dataset.canEdit === "1";
const canDelete = !isReview && !isReadOnly && !pack && Boolean(card.dataset.messageId);
const rollbackMessageId = rollbackHost.dataset.rollbackMessageId ?? null;
const rollbackMode = rollbackHost.dataset.rollbackMode || "keep";
const canRollback = !isReview && !isReadOnly && Boolean(rollbackMessageId);
const hasContext = Boolean(contextTarget);
const editBtn = document.getElementById("msg-menu-edit");
const delBtn = document.getElementById("msg-menu-delete");
const rollbackBtn = document.getElementById("msg-menu-rollback");
const ctxBtn = document.getElementById("msg-menu-context");
if (editBtn) editBtn.toggleAttribute("hidden", !canEdit);
if (delBtn) delBtn.toggleAttribute("hidden", !canDelete);
if (rollbackBtn) rollbackBtn.toggleAttribute("hidden", !canRollback);
if (rollbackBtn) {
rollbackBtn.toggleAttribute("hidden", !canRollback);
rollbackBtn.textContent =
rollbackMode === "pending-input" ? "回到这一轮输入" : "回退到这里";
}
if (ctxBtn) ctxBtn.toggleAttribute("hidden", !hasContext);
menu.hidden = false;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
msgMenuState.messageId = card.dataset.messageId ?? null;
msgMenuState.rollbackMessageId = card.dataset.rollbackMessageId ?? null;
msgMenuState.contextMessageId = contextTarget?.dataset.messageId ?? card.dataset.messageId ?? null;
msgMenuState.rollbackMessageId = rollbackMessageId;
msgMenuState.rollbackMode = rollbackMode;
msgMenuState.restoreDraft = rollbackHost.dataset.restoreDraft ?? "";
msgMenuState.card = card;
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
@@ -193,12 +233,15 @@ function wireMsgActionMenu(handlers) {
menu.addEventListener("click", async (e) => {
const action = e.target.closest("[data-msg-menu-action]")?.getAttribute("data-msg-menu-action");
const messageId = msgMenuState.messageId;
const contextMessageId = msgMenuState.contextMessageId || messageId;
const rollbackMessageId = msgMenuState.rollbackMessageId;
const rollbackMode = msgMenuState.rollbackMode;
const restoreDraft = msgMenuState.restoreDraft;
const card = msgMenuState.card;
hideMsgMenu();
if (!messageId || !action) return;
const bodyEl = card?.querySelector(".msg-body:not(.msg-body-editing), .coord-text");
const body = bodyEl?.textContent ?? card?.dataset.originalText ?? "";
const body = card?.dataset.originalText || bodyEl?.textContent || "";
if (action === "copy") {
try {
@@ -209,7 +252,7 @@ function wireMsgActionMenu(handlers) {
return;
}
if (action === "view-context") {
msgMenuState.handlers?.onViewContext?.(messageId);
msgMenuState.handlers?.onViewContext?.(contextMessageId);
return;
}
if (action === "edit") {
@@ -218,8 +261,20 @@ function wireMsgActionMenu(handlers) {
}
if (action === "rollback") {
if (!rollbackMessageId) return;
if (!confirm("回退到这条消息?之后的对话和创作状态将被移除。")) return;
msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId);
const pendingInput = rollbackMode === "pending-input";
const confirmed = pendingInput
? confirm(
"回到这一轮的待输入?这一轮及之后的对话和创作状态将被移除,原文会填回底栏。",
)
: confirm("回退到这条消息?之后的对话和创作状态将被移除。");
if (!confirmed) return;
if (pendingInput) {
const restore = msgMenuState.handlers?.onRollbackToInput;
if (typeof restore === "function") restore(rollbackMessageId, restoreDraft);
else msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId);
} else {
msgMenuState.handlers?.onDeleteMessage?.(rollbackMessageId);
}
return;
}
if (action === "delete") {
@@ -234,7 +289,7 @@ function wireMessageContextMenu(feed, handlers) {
feed.dataset.contextWired = "1";
wireMsgActionMenu(handlers);
feed.addEventListener("contextmenu", (e) => {
const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line");
const bubble = e.target.closest(".msg-bubble, .workspace-review, .coord-line, .coord-pack");
const card = e.target.closest("[data-message-id]");
if (!bubble || !card || card.classList.contains("msg-pending")) return;
e.preventDefault();
@@ -423,22 +478,40 @@ function formatQuestionsHtml(body) {
}
/** play 期助手终稿(含已验收的用户展示)用呈现壳渲染 */
function isPlayHousekeepingMessage(msg) {
const title = String(msg.title ?? "");
const text = String(msg.text ?? msg.body ?? "").trim();
if (title === "上下文已压缩" || title === "阶段机") return true;
if (/^\[上下文已压缩\]/.test(text)) return true;
if (/^\[裁决合并\]/.test(text)) return true;
if (/^\[旁观维护\]/.test(text)) return true;
if (/归档\s*\d+\s*个过程 tag/.test(text)) return true;
return false;
}
function shouldRenderPlayPresent(msg, view) {
if (view?.lifecycleStage !== "play") return false;
if (!isPlayView(view)) return false;
if (msg.role === "user") return false;
const kind = msg.kind ?? "";
if (kind === "worker_questions" || kind === "orchestrator_thinking") return false;
if (kind === "worker_output") return false; // 已走 formatArtifactBodyHtml
if (isPlayHousekeepingMessage(msg)) return false;
const actor = String(msg.actor ?? "");
if (actor === "narrator" || /用户展示|开场白/.test(String(msg.title ?? ""))) {
return true;
}
// 助手可见终稿:无特殊 kind 的长文
return kind === "system_info" || kind === "worker_stub" || !kind;
const title = String(msg.title ?? "");
return actor === "narrator" || actor === "round-present" || /用户展示|开场白/.test(title);
}
function isPlayFinalReply(msg, view) {
if (!isPlayView(view) || msg.role === "user") return false;
return shouldRenderPlayPresent(msg, view);
}
function isPlayProcessMessage(msg, view) {
if (!isPlayView(view) || msg.role === "user") return false;
return !isPlayFinalReply(msg, view);
}
function formatPlayPresentHtml(body, view) {
const trimmed = (body || "").trim();
const trimmed = stripPresentSourceFences((body || "").trim());
if (!trimmed) return `<p class="empty-sm">(无正文)</p>`;
const tweaks = view?.presentationTweaks;
const fallbackShell =
@@ -562,7 +635,7 @@ function wireRailChrome() {
function hasStartedCreation(view) {
if (!view?.id) return false;
if (view.lifecycleStage === "play") return true;
if (isPlayView(view)) return true;
if (hasUserMessages(view)) return true;
const reason = view.waitingReason?.kind;
if (reason && reason !== "intake") return true;
@@ -626,6 +699,7 @@ function shouldShowInFeed(msg, view) {
if (HIDE_KINDS.has(kind)) return false;
if (FEED_HIDDEN_KINDS.has(kind)) return false;
if (msg.compressed) return false;
if (isPlayView(view) && isPlayHousekeepingMessage(msg)) return false;
if (isSkillSelectionMessage(msg)) return false;
if (isReviewSidecarQuestionStub(msg)) return false;
if (
@@ -635,11 +709,11 @@ function shouldShowInFeed(msg, view) {
) {
return false;
}
if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact") {
if (kind === "worker_output" && view.waitingReason?.kind === "review_artifact" && !isPlayView(view)) {
return false;
}
// 创作:编排器思考进「调度」,不占主区;游玩保持原样
if (kind === "orchestrator_thinking" && view.lifecycleStage !== "play") {
// 创作:编排器思考进「调度」,不占主区;游玩保持原样(默认折叠)
if (kind === "orchestrator_thinking" && !isPlayView(view)) {
return false;
}
if (kind === "orchestrator_prompt") return false;
@@ -661,10 +735,10 @@ function maybeSyncRail(view) {
}
export function renderLifecycle(view) {
const toggle = document.getElementById("lifecycle-toggle");
if (!toggle) return;
const stage = view.lifecycleStage ?? "design";
document.body.dataset.lifecycle = stage;
const toggle = document.getElementById("lifecycle-toggle");
if (!toggle) return;
toggle.querySelectorAll("[data-stage]").forEach((btn) => {
const s = btn.getAttribute("data-stage");
btn.classList.toggle("active", s === stage);
@@ -1541,7 +1615,7 @@ function renderKnownArtifactHtml(doc, opts = {}) {
if (schema === "context-fragment.v1" || isContextFragmentLike(doc)) {
return renderContextFragmentHtml(doc, opts);
}
if (schema === "context-order.v1" || Array.isArray(doc.slots)) {
if (schema === "context-order.v1" || Array.isArray(doc.slots) || Array.isArray(doc.agents)) {
const orderHtml = renderContextOrderHtml(doc);
if (orderHtml) return orderHtml;
}
@@ -1666,6 +1740,105 @@ function renderContextFragmentHtml(doc, opts = {}) {
return `<div class="artifact-friendly artifact-context-fragment">${parts.join("")}</div>`;
}
/** 开场白终节点:1~多条候选用呈现壳展示,选定后由程序落库 */
function collectOpeningCandidateTexts(body) {
const texts = [];
const push = (value) => {
const text = String(value ?? "").trim();
if (text && !texts.includes(text)) texts.push(text);
};
push(body.开场白全文);
if (Array.isArray(body.开场白候选)) {
for (const item of body.开场白候选) {
if (typeof item === "string") push(item);
else if (item && typeof item === "object") {
push(item.全文 || item.text || item.开场白 || item.内容);
}
}
}
return texts;
}
function renderOpeningSetupBodyHtml(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) return "";
const candidates = collectOpeningCandidateTexts(body);
if (!candidates.length) return "";
const total = candidates.length;
const slides = candidates
.map((text, i) => {
const hidden = i === 0 ? "" : " hidden";
const selected = i === 0 ? " is-selected" : "";
return `<article class="opening-candidate${selected}" data-opening-index="${i}"${hidden}>${renderPresentShellHtml(
presentFromPlain(text, "prose"),
esc,
)}</article>`;
})
.join("");
const nav =
total > 1
? `<div class="opening-picker-nav">
<button type="button" class="opening-picker-btn" data-opening-delta="-1">上一版</button>
<span class="opening-picker-pos" data-opening-pos>1 / ${total}</span>
<button type="button" class="opening-picker-btn" data-opening-delta="1">下一版</button>
</div>`
: "";
const vars = Array.isArray(body.开场变量) ? body.开场变量 : [];
const varsHtml = vars.length
? `<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>`
: "";
const space = body.用户可行动空间
? `<section class="artifact-block"><h4>可行动空间</h4>${renderProseHtml(
body.用户可行动空间,
)}</section>`
: "";
return `<div class="opening-setup-body">
<div class="opening-picker" data-opening-picker="1" data-selected="0" data-total="${total}">
${slides}
${nav}
</div>
${varsHtml}
${space}
</div>`;
}
function wireOpeningPicker() {
document.querySelectorAll("[data-opening-picker]").forEach((root) => {
if (root.dataset.wired === "1") return;
root.dataset.wired = "1";
const total = Math.max(1, Number(root.getAttribute("data-total")) || 1);
const show = (index) => {
const next = ((index % total) + total) % total;
root.setAttribute("data-selected", String(next));
root.querySelectorAll("[data-opening-index]").forEach((el) => {
const i = Number(el.getAttribute("data-opening-index"));
const on = i === next;
el.toggleAttribute("hidden", !on);
el.classList.toggle("is-selected", on);
});
const pos = root.querySelector("[data-opening-pos]");
if (pos) pos.textContent = `${next + 1} / ${total}`;
};
root.addEventListener("click", (e) => {
const btn = e.target.closest("[data-opening-delta]");
if (!btn || !root.contains(btn)) return;
const delta = Number(btn.getAttribute("data-opening-delta"));
if (delta !== 1 && delta !== -1) return;
const cur = Number(root.getAttribute("data-selected") || 0);
show(cur + delta);
});
});
}
/** 按技能分流正文视图;无专用模板则返回 ""(调用方走通用结构化) */
function renderSpecialtyBodyHtml(body, skill) {
if (!body || typeof body !== "object" || Array.isArray(body)) return "";
@@ -1703,6 +1876,22 @@ function renderSpecialtyBodyHtml(body, skill) {
const html = renderNarrativeBodyHtml(body);
if (html) return html;
}
if (
body.呈现壳 != null ||
body.可见块 != null ||
(body.示例 != null && (skillName.includes("正文组成") || skillName.includes("回复格式")))
) {
const html = renderReplyFormatBodyHtml(body);
if (html) return html;
}
if (
body.开场白全文 != null ||
body.开场白候选 != null ||
skillName.includes("开场白")
) {
const html = renderOpeningSetupBodyHtml(body);
if (html) return html;
}
const looksAesthetics =
body.设定逻辑 != null ||
@@ -1782,6 +1971,101 @@ function renderSpecialtyBodyHtml(body, skill) {
</div>`;
}
/** 正文组成 · 壳适配 + 示例灌数(present 壳预览,验收美化) */
function renderReplyFormatBodyHtml(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) return "";
const shellDoc = body.呈现壳 && typeof body.呈现壳 === "object" ? body.呈现壳 : {};
const shellId =
typeof shellDoc.shell_id === "string" && PRESENT_SHELL_IDS.includes(shellDoc.shell_id)
? shellDoc.shell_id
: typeof shellDoc.shell_id === "string"
? shellDoc.shell_id
: "";
const tweaks =
shellDoc.微调 && typeof shellDoc.微调 === "object" && !Array.isArray(shellDoc.微调)
? shellDoc.微调
: {};
const example = body.示例 && typeof body.示例 === "object" ? body.示例 : null;
const packetRaw = example?.灌数;
const presentView =
packetRaw && typeof packetRaw === "object"
? parsePresentDoc(
{
...packetRaw,
shell: packetRaw.shell || packetRaw.shell_id || shellId || "prose",
},
shellId || "prose",
)
: null;
const parts = [];
parts.push(`<div class="af-split-banner" role="note">
<span><b>本步看壳与美化</b></span>
<span>示例非正史 · 文笔不重要</span>
</div>`);
const metaBits = [];
if (shellId) {
metaBits.push(`<span class="ws-badge ws-badge-continue">壳 ${esc(shellId)}</span>`);
}
if (shellDoc.为何选它) {
metaBits.push(`<span class="ws-muted">${esc(String(shellDoc.为何选它))}</span>`);
}
if (body.版式隐喻) {
metaBits.push(`<span class="ws-muted">隐喻:${esc(String(body.版式隐喻))}</span>`);
}
if (metaBits.length) {
parts.push(`<div class="artifact-chip-row reply-format-shell-meta">${metaBits.join("")}</div>`);
}
if (presentView) {
const nature = example?.性质 ? esc(String(example.性质)) : "验收预览|非正史";
const explain = example?.用来说明 ? `<p class="ws-muted">${esc(String(example.用来说明))}</p>` : "";
parts.push(`<section class="af-panel af-panel-wide reply-format-preview">
<h4>版式预览 <span class="ws-badge">${nature}</span></h4>
${explain}
${renderPresentShellHtml(presentView.packet, esc, { tweaks })}
</section>`);
} else {
parts.push(
`<section class="af-panel"><p class="ws-muted">(暂无示例灌数;定稿时应给 present.v1 预览)</p></section>`,
);
}
const contractKeys = [
"依据的体验与呈现",
"呈现壳",
"版式隐喻",
"可见块",
"监控栏锚点",
"隐藏段",
"前端拆分与美化",
"拼装与终稿",
];
const used = new Set(["示例"]);
for (const key of contractKeys) {
if (body[key] == null || body[key] === "") continue;
used.add(key);
parts.push(
`<details class="skill-example reply-format-contract"><summary>${esc(key)}</summary>${renderStructuredValueHtml(
body[key],
0,
)}</details>`,
);
}
for (const [k, v] of Object.entries(body)) {
if (used.has(k) || v == null || v === "") continue;
parts.push(
`<details class="skill-example reply-format-contract"><summary>${esc(k)}</summary>${renderStructuredValueHtml(
v,
0,
)}</details>`,
);
}
return `<div class="artifact-friendly artifact-reply-format">${parts.join("")}</div>`;
}
/** 叙事指南 · 一份全文结构化卡(不强调双投影裁剪) */
function renderNarrativeBodyHtml(body) {
const order = [
@@ -2528,7 +2812,10 @@ function renderSelfScoreHtml(自评) {
function renderProbeHtml(追问) {
if (!追问 || typeof 追问 !== "object") return "";
const qs = Array.isArray(追问.题目) ? 追问.题目 : [];
const qs = Array.isArray(追问.题目)
? 追问.题目.filter((q) => q && typeof q === "object" && (q.问 || q.prompt))
: [];
if (!qs.length) return "";
const cards = [];
if (追问.导语) {
cards.push(`<p class="artifact-probe-lead">${esc(String(追问.导语))}</p>`);
@@ -2555,7 +2842,13 @@ function renderProbeHtml(追问) {
}
function renderContextOrderHtml(doc) {
if (!Array.isArray(doc.slots) || !doc.slots.length) return null;
const slots =
Array.isArray(doc.slots) && doc.slots.length
? doc.slots
: Array.isArray(doc.agents)
? doc.agents.filter((a) => a && a.enabled !== false)
: [];
if (!slots.length) return null;
const parts = [];
if (doc.brief) {
parts.push(
@@ -2564,7 +2857,7 @@ function renderContextOrderHtml(doc) {
}
for (const slot of doc.slots) {
if (!slot || typeof slot !== "object") continue;
const title = slot.label || slot.ref || "槽";
const title = slot.label || slot.ref || slot.id || "槽";
const inserts = Array.isArray(slot.inserts) ? slot.inserts : [];
if (!inserts.length && Array.isArray(slot.order)) {
// 扁平序:直接是条目列表
@@ -2919,7 +3212,7 @@ function renderCreationFlowView(flowView) {
return `<div class="review-parse-error" role="alert">
<div class="review-parse-error-title">流程无法解析</div>
<p>${esc(flowView.parseError)}</p>
<p class="review-parse-error-hint">需要 JSON:steps 数组,每步含 name(与可选 id)、depends_on、可选 params;可含 status=open|closed。</p>
<p class="review-parse-error-hint">需要 JSON:steps 数组,每步含 name(与可选 id)、depends_on、可选 params;可含 status=open|closed。回头修改用 mode=revise 与 revises。</p>
</div>`;
}
if (!flowView.steps?.length) return "";
@@ -2943,7 +3236,9 @@ function renderCreationFlowView(flowView) {
? s.depends_on.map((d) => esc(d)).join("、")
: "无";
const occ =
s.occurrence && s.occurrence > 1
s.mode === "revise"
? `<span class="flow-occ flow-occ-revise">回头修改</span>`
: s.occurrence && s.occurrence > 1
? `<span class="flow-occ">第 ${esc(String(s.occurrence))} 次</span>`
: s.repeatable
? `<span class="flow-occ">可反复</span>`
@@ -3068,12 +3363,16 @@ function renderReviewWorkspace(review, opts = {}) {
asPresentFallback:
review.workerId === "narrator" ||
review.workerId === "opening-generator" ||
(Array.isArray(review.outputTags) &&
review.outputTags.includes("设计.开场白与开场变量")) ||
/用户展示|开场白/.test(review.body || ""),
});
const contextId = review.sourceMessageId || review.id || "";
const hasContext = review.contextTrace ? "1" : "0";
const label = displayWorkerLabel(review.workerId) || "产物";
const copy = reviewComposerCopy(review.workerId);
const copy = reviewComposerCopy(review.workerId, {
outputTags: review.outputTags,
});
const sourceMsg =
opts.sourceMessage ||
(review.sourceMessageId
@@ -3211,25 +3510,65 @@ export function renderAgentPanel(view, loading) {
timelineEl.scrollTop = timelineEl.scrollHeight;
}
function coordOriginalText(msg) {
return String(msg?.body ?? msg?.text ?? "").trim();
}
/** 一轮 = 一次用户输入 + 直到下一次输入前的全部输出。 */
function groupCoordTurnPacks(messages) {
const packs = [];
for (const msg of messages) {
const isUser = msg.role === "user";
const last = packs[packs.length - 1];
if (isUser || !last) {
packs.push({ user: isUser ? msg : null, outputs: isUser ? [] : [msg] });
} else {
last.outputs.push(msg);
}
}
return packs;
}
function packAnchorMessage(pack) {
return pack.user || pack.outputs[0] || null;
}
function packLastMessage(pack) {
return pack.outputs[pack.outputs.length - 1] || pack.user || null;
}
function renderCoordLine(msg, view, opts = {}) {
const isUser = msg.role === "user";
const kind = msg.kind ?? (isUser ? "user_input" : "system_info");
const who = isUser ? "你" : msgLabel(msg);
const full = coordOriginalText(msg) || msgBody(msg, view);
const raw = msgBody(msg, view).replace(/\s+/g, " ").trim();
const text = raw.length > 320 ? `${raw.slice(0, 320)}…` : raw;
const nav = renderVariantNavHtml(msg);
const inPack = opts.inPack === true;
const allMessages = view.messages ?? [];
const messageIndex = allMessages.findIndex((item) => item.id === msg.id);
const rollbackMessageId = messageIndex >= 0 ? allMessages[messageIndex + 1]?.id : null;
const rollbackMessageId = inPack ? null : getRollbackMessageId(allMessages, msg.id);
const rollbackAttr = rollbackMessageId
? ` data-rollback-message-id="${esc(rollbackMessageId)}"`
: "";
const contextAttr = msg.contextTrace ? ` data-has-context="1"` : "";
const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : "";
const originalAttr = full ? ` data-original-text="${escAttr(full)}"` : "";
const menu = inPack
? ""
: `<button type="button" class="coord-line-menu" data-msg-menu-trigger aria-label="打开这条消息的操作菜单" title="更多操作">⋯</button>`;
const trace = msg.contextTrace;
const variantTotal = Math.max(1, Number(msg.branchTotal) || 1);
const variantIndex = Math.min(variantTotal, Math.max(1, (msg.branchIndex ?? 0) + 1));
const requestBits = [
opts.requestIndex ? `请求 ${opts.requestIndex}` : "",
variantTotal > 1 ? `${variantIndex}/${variantTotal} 版` : "",
].filter(Boolean);
const requestHint = requestBits.length ? requestBits.join(" · ") : "本次请求";
const traceMeta = trace
? `<button type="button" class="coord-context-trigger" data-context-trigger aria-label="查看 ${esc(who)} 的完整请求上下文">
? `<button type="button" class="coord-context-trigger" data-context-trigger aria-label="查看 ${esc(who)} 的${esc(requestHint)}上下文">
<span class="coord-context-label">保留上下文</span>
<span class="coord-context-request">${esc(requestHint)}</span>
<span>${trace.messages?.length ?? 0} 段</span>
<span>${((trace.charCount ?? 0) / 1024).toFixed(1)} KB</span>
</button>
@@ -3240,14 +3579,73 @@ function renderCoordLine(msg, view, opts = {}) {
</div>`
: "";
const traceClass = trace ? " coord-line--trace" : "";
return `<div class="coord-line ${isUser ? "coord-user" : "coord-agent"}${traceClass}" data-message-id="${esc(msg.id)}" data-kind="${esc(kind)}"${rollbackAttr}${contextAttr}${readOnlyAttr}>
<button type="button" class="coord-line-menu" data-msg-menu-trigger aria-label="打开这条消息的操作菜单" title="更多操作">⋯</button>
return `<div class="coord-line ${isUser ? "coord-user" : "coord-agent"}${traceClass}" data-message-id="${esc(msg.id)}" data-kind="${esc(kind)}"${rollbackAttr}${contextAttr}${readOnlyAttr}${originalAttr}>
${menu}
<div class="coord-who"><span>${esc(who)}</span><span class="coord-kind">${esc(kind)}</span><span class="coord-time">${fmtTime(msg.createdAt)}</span>${nav}</div>
<div class="coord-text">${esc(text || "(空)")}</div>
${traceMeta}
</div>`;
}
function renderCoordPack(pack, view, opts = {}) {
const anchor = packAnchorMessage(pack);
if (!anchor) return "";
const last = packLastMessage(pack);
const pending = Boolean(pack.user) && pack.outputs.length === 0;
const userText = pack.user ? coordOriginalText(pack.user) : "";
const allMessages = view.messages ?? [];
let rollbackMode = "";
let rollbackId = "";
if (pack.user) {
rollbackMode = "pending-input";
rollbackId = pack.user.id;
} else if (last) {
const nextId = getRollbackMessageId(allMessages, last.id);
if (nextId) {
rollbackMode = "keep";
rollbackId = nextId;
}
}
const inCount = pack.user ? 1 : 0;
const outCount = pack.outputs.length;
const meta = pending ? "待输入" : `${inCount} 入 · ${outCount} 出`;
const rollbackAttr = rollbackId
? ` data-rollback-message-id="${esc(rollbackId)}" data-rollback-mode="${esc(rollbackMode)}"`
: "";
const restoreAttr =
rollbackMode === "pending-input" && userText
? ` data-restore-draft="${escAttr(userText)}"`
: "";
const originalAttr = userText ? ` data-original-text="${escAttr(userText)}"` : "";
const readOnlyAttr = opts.traceOnly ? ` data-read-only="1"` : "";
const tracedOutputCount = pack.outputs.filter((msg) => msg.contextTrace).length;
let requestIndex = 0;
const lines = [
pack.user
? renderCoordLine(pack.user, view, {
inPack: true,
traceOnly: opts.traceOnly || opts.hiddenIds?.has(pack.user.id),
})
: "",
...pack.outputs.map((msg) => {
const thisRequest =
tracedOutputCount > 1 && msg.contextTrace ? ++requestIndex : 0;
return renderCoordLine(msg, view, {
inPack: true,
traceOnly: opts.traceOnly || opts.hiddenIds?.has(msg.id),
requestIndex: thisRequest || undefined,
});
}),
].join("");
return `<article class="coord-pack${pending ? " coord-pack--pending" : ""}" data-message-id="${esc(anchor.id)}"${rollbackAttr}${restoreAttr}${originalAttr}${readOnlyAttr}>
<button type="button" class="coord-line-menu" data-msg-menu-trigger aria-label="打开这一轮的操作菜单" title="更多操作">⋯</button>
<header class="coord-pack-head">
<span class="coord-pack-meta">${esc(meta)}</span>
</header>
${lines}
</article>`;
}
function getRollbackMessageId(messages, messageId) {
const messageIndex = messages.findIndex((item) => item.id === messageId);
return messageIndex >= 0 ? messages[messageIndex + 1]?.id ?? null : null;
@@ -3255,7 +3653,7 @@ function getRollbackMessageId(messages, messageId) {
/** @returns {"speak"|"answer"|"review"|"busy"|null} */
function resolveDesignSurface(view, loading) {
if (view.lifecycleStage === "play") return null;
if (isPlayView(view)) return null;
if (view.phase === "done") return null;
const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason));
if (busy) return "busy";
@@ -3300,6 +3698,12 @@ function proposedOutputCopy(proposed) {
typeof target === "string" || typeof target === "number"
? String(target).trim()
: "";
if (proposed?.mode === "revise") {
if (targetText) {
return `将在既有「${targetText}」的${proposed.name}上继续改,不是从零再生成。`;
}
return `将在既有「${proposed?.name || "该步"}」上继续改,不是从零再生成。`;
}
if (targetText) {
return `将生成「${targetText}」的${proposed.name}。`;
}
@@ -3347,17 +3751,24 @@ function renderSpeakWorkspace(view) {
hint = "写清楚要改哪里;发送后在现有产物上修改。";
} else if (wr?.kind === "next_intent") {
title = "下一步想写什么";
hint = "说说接下来想做什么;可留空,发送后会展示下一节点供确认。";
hint = "说说接下来想做什么;可留空,也可说回头改某步。发送后会展示下一节点供确认。";
} else if (wr?.kind === "approve_step") {
title = view.proposedNextStep?.name
? `接下来生成 · ${view.proposedNextStep.name}`
? view.proposedNextStep.mode === "revise"
? `回头修改 · ${view.proposedNextStep.name}`
: `接下来生成 · ${view.proposedNextStep.name}`
: "确认下一步";
hint = view.proposedNextStep
? proposedOutputCopy(view.proposedNextStep)
: view.focus?.detail || "确认执行,或在底栏说明意见。";
} else if (wr?.kind === "input") {
title = "继续说";
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
if (wr.message && /创作已收口/.test(wr.message)) {
title = "创作已收口";
hint = wr.message;
} else {
title = "继续说";
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
}
} else if (view.uiPrompt && !hasUserMessages(view)) {
title = "描述你想创作什么";
hint = String(view.uiPrompt).trim().slice(0, 280);
@@ -3400,19 +3811,25 @@ function fillCoordRail(visible, view, handlers) {
return visibleIds.has(message.id) || Boolean(message.contextTrace);
});
const traceCount = history.filter((message) => message.contextTrace).length;
const packs = groupCoordTurnPacks(history);
const hiddenIds = new Set(
history.filter((message) => !visibleIds.has(message.id)).map((message) => message.id),
);
coordRail.hidden = false;
if (drawerBody) {
drawerBody.innerHTML = history.length
? history
.map((m) => renderCoordLine(m, view, { traceOnly: !visibleIds.has(m.id) }))
.join("")
drawerBody.innerHTML = packs.length
? packs.map((pack) => renderCoordPack(pack, view, { hiddenIds })).join("")
: `<p class="coord-empty">暂无对话摘要</p>`;
wireMessageFeedActions(drawerBody, handlers);
wireMessageContextMenu(drawerBody, handlers);
}
if (drawerCount) {
drawerCount.textContent = history.length ? String(history.length) : "";
drawerCount.title = traceCount ? `${traceCount} 条保留了完整请求上下文` : "";
drawerCount.textContent = packs.length ? String(packs.length) : "";
drawerCount.title = traceCount
? `${packs.length} 轮,其中 ${traceCount} 条保留了完整请求上下文`
: packs.length
? `${packs.length} 轮对话`
: "";
}
syncCoordRailChrome();
}
@@ -3444,6 +3861,7 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) {
}) + askSlot;
wireMessageFeedActions(stage, handlers);
wireMessageContextMenu(stage, handlers);
wireOpeningPicker();
if (opts.mountAskCard) {
const askHost = document.getElementById("workspace-review-ask");
if (askHost) ensureQuestionsHostIn(askHost);
@@ -3466,13 +3884,16 @@ export function renderMessageFeed(view, loading, handlers = {}) {
const surface = resolveDesignSurface(view, loading);
const designWorkspace = surface != null;
const reviewingNow =
view.waitingReason?.kind === "review_artifact" && Boolean(view.reviewArtifact);
!isPlayView(view) &&
view.waitingReason?.kind === "review_artifact" &&
Boolean(view.reviewArtifact);
document.body.classList.toggle("is-reviewing", reviewingNow);
document.body.classList.toggle("design-workspace", designWorkspace);
if (reviewingNow) {
document.body.dataset.reviewKind = reviewComposerCopy(
view.reviewArtifact?.workerId,
{ outputTags: view.reviewArtifact?.outputTags },
).kind;
} else {
delete document.body.dataset.reviewKind;
@@ -3529,9 +3950,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
<header class="workspace-pending-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>`;
wireRetryRun(stage, handlers);
return;
}
@@ -3565,8 +3988,8 @@ export function renderMessageFeed(view, loading, handlers = {}) {
if (!visible.length && !loading) {
const p = document.createElement("p");
p.className = "empty";
if (view.lifecycleStage === "play") {
p.textContent = "游玩模式:Agent 将按 Worker 集调度,推进世界与叙事。";
if (isPlayView(view)) {
p.textContent = "开场已就绪。在下方说你要做什么。";
} else if (view.uiPrompt) {
const recipeLine = view.selectedRecipe?.name
? `\n\n已选配方:${view.selectedRecipe.name}`
@@ -3621,31 +4044,48 @@ export function renderMessageFeed(view, loading, handlers = {}) {
const questionsActive = kind === "worker_questions" && Boolean(activeQuestions);
const showThinking =
!isUser && msg.thinking && view.lifecycleStage === "play";
const bodyHtml = questionsActive
!isUser && msg.thinking && isPlayView(view);
const innerBody = questionsActive
? `<p class="msg-q-index">${esc(body)}</p>`
: kind === "worker_questions"
? formatQuestionsHtml(body)
: kind === "worker_output"
? formatArtifactBodyHtml(body)
: shouldRenderPlayPresent(msg, view)
? formatPlayPresentHtml(body, view)
: shouldRenderPlayPresent(msg, view)
? formatPlayPresentHtml(body, view)
: kind === "worker_output"
? formatArtifactBodyHtml(body)
: esc(body);
card.innerHTML = `
<div class="msg-bubble${questionsActive ? " msg-bubble-index" : ""}">
${isUser ? "" : `<header class="msg-head">${headInner}${menuTrigger}</header>`}
${showThinking ? renderThinkingBlock(msg.thinking) : ""}
<div class="msg-body">${bodyHtml}</div>
${isUser ? `<footer class="msg-foot">${headInner}${menuTrigger}</footer>` : ""}
</div>`;
const process = !isUser && isPlayProcessMessage(msg, view);
const playFinal = !isUser && isPlayFinalReply(msg, view);
if (process) {
card.classList.add("msg-play-process-row");
card.innerHTML = `
<details class="msg-play-process">
<summary>
<span class="msg-tag">${esc(label)}</span>
${showSubtitle ? `<span class="msg-subtitle">${esc(subtitle)}</span>` : ""}
<span class="msg-time">${fmtTime(msg.createdAt)}</span>
</summary>
${showThinking ? renderThinkingBlock(msg.thinking) : ""}
<div class="msg-body">${innerBody}</div>
</details>`;
} else {
if (playFinal) card.classList.add("msg-play-final");
card.innerHTML = `
<div class="msg-bubble${questionsActive ? " msg-bubble-index" : ""}">
${isUser ? "" : `<header class="msg-head">${headInner}${menuTrigger}</header>`}
${showThinking ? renderThinkingBlock(msg.thinking) : ""}
<div class="msg-body">${innerBody}</div>
${isUser ? `<footer class="msg-foot">${headInner}${menuTrigger}</footer>` : ""}
</div>`;
}
feed.appendChild(card);
}
wireMessageFeedActions(feed, handlers);
wireMessageContextMenu(feed, handlers);
if (reviewingNow && view.reviewArtifact && !loading && stage) {
if (reviewingNow && view.reviewArtifact && !loading && stage && !isPlayView(view)) {
const hasHungAsk = Boolean(activeQuestions?.questions?.length);
mountReviewWorkspace(stage, view, handlers, {
hideAskSidecar: hasHungAsk,
@@ -3661,10 +4101,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
const label = live?.label ?? view.focus?.action ?? "处理中";
pending.innerHTML = `
<div class="msg-bubble">
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span></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>`;
feed.appendChild(pending);
wireRetryRun(pending, handlers);
}
feed.scrollTop = feed.scrollHeight;
@@ -3682,7 +4123,7 @@ export function updateLiveStreamPanel(view) {
if (labelEl && live?.label) labelEl.textContent = live.label;
const next = renderLiveStreamBody(live, view);
const hadThinking = Boolean(body.querySelector(".msg-live-pre"));
if (next === "…" && hadThinking) return;
if (next.includes("msg-live-empty") && hadThinking) return;
body.innerHTML = next;
const pre = body.querySelector(".msg-live-pre:last-of-type");
if (pre) pre.scrollTop = pre.scrollHeight;
+464 -275
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -101,6 +101,10 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
export function reviewComposerCopy(workerId, opts = {}) {
const hasQuestions = Boolean(opts.hasQuestions);
const id = String(workerId ?? "").trim();
const openingReview =
id === "opening-generator" ||
(Array.isArray(opts.outputTags) &&
opts.outputTags.includes("设计.开场白与开场变量"));
if (id === "design-flow") {
return {
@@ -120,7 +124,7 @@ export function reviewComposerCopy(workerId, opts = {}) {
};
}
if (id === "opening-generator") {
if (id === "opening-generator" || openingReview) {
return {
chip: "开场",
kicker: "待选定",
+22 -32
View File
@@ -35,7 +35,6 @@
<div class="rail-panels">
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books">
<nav class="path-bar" id="book-path-bar" aria-label="面包屑" hidden></nav>
<div class="book-list" id="book-list"></div>
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
</div>
@@ -48,39 +47,11 @@
</aside>
<main class="panel panel-main">
<header class="main-head" id="main-head">
<div class="main-head-left">
<h1 class="work-title" id="work-title">未打开作品</h1>
<span class="work-meta" id="work-meta"></span>
</div>
<div class="main-head-center">
<div class="lifecycle-toggle" id="lifecycle-toggle" role="group" aria-label="创作 / 游玩">
<button type="button" class="lifecycle-btn active" data-stage="design">创作</button>
<button type="button" class="lifecycle-btn" data-stage="play" disabled>游玩</button>
</div>
</div>
<div class="main-head-right">
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button>
<button type="button" class="btn-sm" id="btn-save-play" hidden>保存游玩</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
<details class="more-menu">
<summary class="btn-sm more-menu-sum" title="更多页面">更多</summary>
<div class="more-menu-panel" role="menu">
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
</div>
</details>
</div>
</header>
<div class="main-body">
<section class="panel-feed" id="panel-feed">
<div id="skill-picker" class="skill-picker" hidden></div>
<div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p>
<p class="empty">正在打开作品…</p>
</div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div>
@@ -106,7 +77,22 @@
</aside>
</div>
<footer class="composer" id="composer"></footer>
<footer class="composer" id="composer">
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
<div id="composer-main"></div>
<details class="more-menu">
<summary class="btn-sm more-menu-sum" title="更多">更多</summary>
<div class="more-menu-panel" role="menu">
<button type="button" class="more-menu-item" id="btn-save-instance" hidden role="menuitem">保存定稿</button>
<button type="button" class="more-menu-item" id="btn-save-play" hidden role="menuitem">保存游玩</button>
<button type="button" class="more-menu-item" id="btn-export" disabled role="menuitem">导出</button>
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
</div>
</details>
</footer>
</main>
</div>
@@ -133,7 +119,11 @@
</dialog>
<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-save-instance" data-action="save-instance">保存定稿</button>
<button type="button" class="context-menu-item" id="book-menu-save-play" data-action="save-play">保存游玩</button>
<button type="button" class="context-menu-item" id="book-menu-export" data-action="export">导出</button>
<button type="button" class="context-menu-item" id="book-menu-rename" data-action="rename">重命名</button>
<button type="button" class="context-menu-item" id="book-menu-duplicate" data-action="duplicate">复制备份</button>
<button type="button" class="context-menu-item" id="book-menu-select" data-action="select-mode">多选</button>
+16 -1
View File
@@ -82,6 +82,20 @@ function formatBlockContent(value) {
return String(value);
}
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
export function stripPresentSourceFences(raw) {
let s = String(raw ?? "").trim();
for (let i = 0; i < 3; i += 1) {
const next = s
.replace(/^(#{1,6}\s*)?输出[.::]?\s*(用户展示|开场白)(?:\s*\n+|\s*$)/u, "")
.replace(/^【\s*输出[.::]?\s*(用户展示|开场白)\s*】(?:\s*\n+|\s*$)/u, "")
.trim();
if (next === s) break;
s = next;
}
return s;
}
function normalizeBlocks(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out = {};
@@ -90,6 +104,7 @@ function normalizeBlocks(raw) {
}
if (out.body === undefined && raw.正文 !== undefined) out.body = raw.正文;
if (out.monitor === undefined && raw.监控 !== undefined) out.monitor = raw.监控;
if (typeof out.body === "string") out.body = stripPresentSourceFences(out.body);
return out;
}
@@ -162,7 +177,7 @@ export function presentFromPlain(text, shell = "prose") {
return {
schema: "present.v1",
shell: isPresentShellId(shell) ? shell : "prose",
blocks: { body: text ?? "" },
blocks: { body: stripPresentSourceFences(text ?? "") },
meta: { suggested_actions: [] },
};
}
+20 -34
View File
@@ -4,6 +4,7 @@
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
* 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受。
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
* 点顶栏横条折叠成一行,再点展开。
*
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
*/
@@ -259,7 +260,7 @@ export function clearQuestionCardState(host, view) {
host._qState = null;
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.classList.remove("is-open", "is-expanded", "is-collapsed", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
}
@@ -275,7 +276,7 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
if (!active?.questions?.length) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.classList.remove("is-open", "is-expanded", "is-collapsed", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
host._qDismissed = null;
@@ -290,7 +291,7 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.classList.remove("is-open", "is-expanded", "is-collapsed", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
return false;
@@ -305,14 +306,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
sessionId: view.id,
key: qKey,
page: 0,
expanded: false,
collapsed: false,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
if (typeof state.expanded !== "boolean") state.expanded = false;
if (typeof state.collapsed !== "boolean") state.collapsed = false;
host._qState = state;
host.classList.toggle("is-expanded", state.expanded);
host.classList.toggle("is-collapsed", state.collapsed);
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active;
@@ -362,8 +363,13 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
${pagerHtml}
${skipHtml}
</div>
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
<button type="button" class="qcard-top" data-qtop title="${state.collapsed ? "展开追问" : "折叠追问"}" aria-expanded="${state.collapsed ? "false" : "true"}" aria-label="${state.collapsed ? "展开追问" : "折叠追问"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
<span class="qcard-collapsed-meta">${esc(
pageCount > 1
? `追问 ${state.page + 1}/${pageCount} · ${pageQs[0]?.prompt ?? "询问"}`
: `追问 · ${pageQs[0]?.prompt ?? "询问"}`,
)}</span>
</button>
<div class="qcard-body">
${dismissNote}
@@ -414,40 +420,21 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
.join("")}
</div>`;
const setExpanded = (next) => {
state.expanded = next;
host.classList.toggle("is-expanded", next);
const setCollapsed = (next) => {
state.collapsed = next;
host.classList.toggle("is-collapsed", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "点击收起" : "点击展开";
top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡");
top.title = next ? "展开追问" : "折叠追问";
top.setAttribute("aria-expanded", next ? "false" : "true");
top.setAttribute("aria-label", next ? "展开追问" : "折叠追问");
}
};
host.querySelector("[data-qtop]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!state.expanded);
});
host.querySelector(".qcard-body")?.addEventListener("click", (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (
t.closest(
".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager",
)
) {
return;
}
// 展开后点题干 / 评估区收起;收起时点内容区展开
if (state.expanded) {
if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) {
setExpanded(false);
}
return;
}
setExpanded(true);
setCollapsed(!state.collapsed);
});
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
@@ -488,7 +475,6 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
section.querySelectorAll(".qcard-opt").forEach((li) => {
li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid);
});
if (!state.expanded) setExpanded(true);
_handlers.onAnswersChange?.();
});
});
+220
View File
@@ -800,6 +800,226 @@ code {
background: rgba(24, 24, 27, 0.4);
}
/* —— Preset probe —— */
.probe-dialog {
border: none;
border-radius: 10px;
padding: 0;
background: var(--st-surface);
color: var(--st-text);
width: min(1080px, calc(100% - 24px));
height: min(780px, calc(100% - 24px));
max-width: none;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18);
}
.probe-dialog[open] {
display: flex;
flex-direction: column;
overflow: hidden;
}
.probe-dialog::backdrop {
background: rgba(24, 24, 27, 0.45);
}
.probe-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--st-border);
flex-shrink: 0;
}
.probe-head h3 {
margin: 0;
font-size: 16px;
font-weight: 650;
}
.probe-sub {
margin: 4px 0 0;
font-size: 12px;
color: var(--st-muted);
}
.probe-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
}
.probe-composer,
.probe-inspect {
min-height: 0;
overflow: auto;
padding: 16px 18px 20px;
}
.probe-composer {
border-right: 1px solid var(--st-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.probe-composer label,
.probe-extras label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--st-muted);
font-weight: 500;
}
.probe-composer textarea,
.probe-extras textarea {
width: 100%;
resize: vertical;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid var(--st-border-strong);
background: var(--st-bg);
color: var(--st-text);
font-size: 13px;
line-height: 1.5;
}
.probe-composer textarea:focus,
.probe-extras textarea:focus {
outline: none;
border-color: #93c5fd;
box-shadow: 0 0 0 3px var(--st-accent-soft);
background: var(--st-surface);
}
.probe-extras {
border: 1px solid var(--st-border);
border-radius: 8px;
padding: 8px 10px 10px;
background: var(--st-bg);
}
.probe-extras summary {
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--st-text);
margin-bottom: 8px;
}
.probe-extras[open] summary {
margin-bottom: 10px;
}
.probe-composer-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: auto;
}
.probe-status {
margin: 0;
font-size: 12px;
color: var(--st-muted);
}
.probe-inspect h4 {
margin: 0 0 10px;
font-size: 12px;
font-weight: 650;
color: var(--st-muted);
letter-spacing: 0.02em;
}
.probe-empty {
margin: 8px 0;
color: var(--st-faint);
font-size: 13px;
}
.probe-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.probe-msg {
border: 1px solid var(--st-border);
border-radius: 8px;
background: var(--st-bg);
overflow: hidden;
}
.probe-msg header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--st-border);
background: var(--st-surface);
}
.probe-role {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--st-accent);
}
.probe-role.user {
color: #0f766e;
}
.probe-role.assistant {
color: #7c3aed;
}
.probe-index {
font-size: 11px;
color: var(--st-faint);
}
.probe-msg pre,
.probe-reply pre {
margin: 0;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
line-height: 1.55;
}
.probe-reply {
margin-top: 18px;
}
@media (max-width: 800px) {
.probe-dialog {
height: calc(100% - 16px);
}
.probe-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.probe-composer {
border-right: none;
border-bottom: 1px solid var(--st-border);
max-height: 42vh;
}
}
.profile-dialog form {
padding: 22px;
display: flex;
+51
View File
@@ -78,6 +78,57 @@
</main>
</div>
<dialog id="preset-probe-dialog" class="probe-dialog">
<header class="probe-head">
<div>
<h3>试跑预设</h3>
<p class="probe-sub" id="probe-preset-name"></p>
</div>
<button type="button" class="btn-secondary" id="probe-close">关闭</button>
</header>
<div class="probe-body">
<section class="probe-composer">
<label>
本轮输入
<textarea id="probe-message" rows="4" placeholder="发给模型的这一轮"></textarea>
</label>
<details class="probe-extras">
<summary>试跑用设定 / 历史</summary>
<label>
历史前(设定)
<textarea id="probe-lore-before" rows="3"></textarea>
</label>
<label>
对话历史
<textarea id="probe-history" rows="4"></textarea>
</label>
<label>
历史后(状态)
<textarea id="probe-lore-after" rows="3"></textarea>
</label>
<label>
本轮后(可选)
<textarea id="probe-post-turn" rows="2"></textarea>
</label>
</details>
<div class="probe-composer-actions">
<button type="button" class="btn-primary" id="probe-send">发送一轮</button>
<p class="probe-status" id="probe-status" hidden></p>
</div>
</section>
<section class="probe-inspect" aria-live="polite">
<h4>发出的上下文</h4>
<div id="probe-messages" class="probe-messages">
<p class="probe-empty">发送后显示实际拼进请求的消息。</p>
</div>
<div id="probe-reply-wrap" class="probe-reply" hidden>
<h4>模型回复</h4>
<pre id="probe-reply"></pre>
</div>
</section>
</div>
</dialog>
<dialog id="profile-dialog" class="profile-dialog">
<form id="profile-form" method="dialog">
<h3 id="profile-dialog-title">新增 API</h3>
+140
View File
@@ -28,6 +28,29 @@ const sectionStorageEl = document.getElementById("section-storage");
const activeSettingsBarEl = document.getElementById("active-settings-bar");
const settingsToastEl = document.getElementById("settings-toast");
const contextTraceKeepEl = document.getElementById("context-trace-keep");
const probeDialog = document.getElementById("preset-probe-dialog");
const probePresetNameEl = document.getElementById("probe-preset-name");
const probeMessageEl = document.getElementById("probe-message");
const probeLoreBeforeEl = document.getElementById("probe-lore-before");
const probeHistoryEl = document.getElementById("probe-history");
const probeLoreAfterEl = document.getElementById("probe-lore-after");
const probePostTurnEl = document.getElementById("probe-post-turn");
const probeSendEl = document.getElementById("probe-send");
const probeStatusEl = document.getElementById("probe-status");
const probeMessagesEl = document.getElementById("probe-messages");
const probeReplyWrapEl = document.getElementById("probe-reply-wrap");
const probeReplyEl = document.getElementById("probe-reply");
const DEFAULT_PROBE_CONTEXT = {
message: "我推开门。",
loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。",
history: "店员:今晚只剩阁楼那间。\n你:好。",
loreAfter: "时间:深夜。地点:旅馆大厅。",
postTurn: "",
};
let probingPresetId = null;
let probeBusy = false;
async function api(path, options = {}) {
const res = await fetch(path, {
@@ -192,6 +215,7 @@ function renderPresets() {
</div>
<div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-preset")}
<button type="button" data-action="probe-preset" data-id="${p.id}">试跑</button>
<button type="button" data-action="toggle-preset-entries" data-id="${p.id}">
${expanded ? "收起条目" : "编辑条目"}
</button>
@@ -476,6 +500,8 @@ presetsListEl.addEventListener("click", async (e) => {
try {
if (action === "activate-preset") {
await activatePreset(id);
} else if (action === "probe-preset") {
openProbeDialog(id);
} else if (action === "toggle-preset-entries") {
await togglePresetEntries(id);
} else if (action === "delete-preset") {
@@ -573,6 +599,120 @@ presetFileEl.addEventListener("change", async () => {
}
});
function resetProbeForm() {
probeMessageEl.value = DEFAULT_PROBE_CONTEXT.message;
probeLoreBeforeEl.value = DEFAULT_PROBE_CONTEXT.loreBefore;
probeHistoryEl.value = DEFAULT_PROBE_CONTEXT.history;
probeLoreAfterEl.value = DEFAULT_PROBE_CONTEXT.loreAfter;
probePostTurnEl.value = DEFAULT_PROBE_CONTEXT.postTurn;
probeStatusEl.hidden = true;
probeStatusEl.textContent = "";
probeReplyWrapEl.hidden = true;
probeReplyEl.textContent = "";
probeMessagesEl.innerHTML =
'<p class="probe-empty">发送后显示实际拼进请求的消息。</p>';
}
function openProbeDialog(presetId) {
const preset = state.presets.find((p) => p.id === presetId);
probingPresetId = presetId;
probePresetNameEl.textContent = preset?.name ?? presetId;
resetProbeForm();
probeDialog.showModal();
probeMessageEl.focus();
probeMessageEl.select();
}
function renderProbeMessages(messages) {
if (!messages?.length) {
probeMessagesEl.innerHTML =
'<p class="probe-empty">这次没有拼出任何消息。</p>';
return;
}
probeMessagesEl.innerHTML = messages
.map(
(m, i) => `
<article class="probe-msg">
<header>
<span class="probe-role ${escapeHtml(m.role)}">${escapeHtml(m.role)}</span>
<span class="probe-index">#${i + 1}</span>
</header>
<pre>${escapeHtml(m.content)}</pre>
</article>`,
)
.join("");
}
function setProbeBusy(busy) {
probeBusy = busy;
probeSendEl.disabled = busy;
probeSendEl.textContent = busy ? "发送中…" : "发送一轮";
}
async function sendProbe() {
if (probeBusy || !probingPresetId) return;
const message = probeMessageEl.value.trim();
if (!message) {
probeStatusEl.hidden = false;
probeStatusEl.textContent = "请填写本轮输入";
probeMessageEl.focus();
return;
}
setProbeBusy(true);
probeStatusEl.hidden = false;
probeStatusEl.textContent = "正在拼装上下文…";
probeReplyWrapEl.hidden = true;
try {
const result = await api(`/api/presets/${probingPresetId}/probe`, {
method: "POST",
body: JSON.stringify({
message,
loreBefore: probeLoreBeforeEl.value,
history: probeHistoryEl.value,
loreAfter: probeLoreAfterEl.value,
postTurn: probePostTurnEl.value,
}),
});
renderProbeMessages(result.messages);
if (result.reply) {
probeReplyWrapEl.hidden = false;
probeReplyEl.textContent = result.reasoning
? `${result.reasoning}\n\n——\n\n${result.reply}`
: result.reply;
}
if (result.error) {
probeStatusEl.textContent = `上下文已拼装,生成失败:${result.error}`;
} else if (result.skipReason) {
probeStatusEl.textContent = result.skipReason;
} else if (result.completed) {
probeStatusEl.textContent = `已发送 ${result.messages.length} 条`;
} else {
probeStatusEl.textContent = `已拼装 ${result.messages.length} 条`;
}
} catch (err) {
probeStatusEl.textContent = err.message;
} finally {
setProbeBusy(false);
}
}
document.getElementById("probe-close")?.addEventListener("click", () => {
probeDialog.close();
});
probeSendEl?.addEventListener("click", () => {
sendProbe().catch((err) => showToast(err.message, true));
});
probeMessageEl?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
sendProbe().catch((err) => showToast(err.message, true));
}
});
document.getElementById("btn-save-context-keep")?.addEventListener("click", async () => {
try {
const n = Number(contextTraceKeepEl?.value ?? 5);
+1 -1
View File
@@ -32,7 +32,7 @@
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
<nav class="shells-list" id="shell-list" role="tablist"></nav>
<p class="st-rail-note">
主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏。
主任务永远是读正文(常 500~2000 字)。侧栏只换壳;长度/语气在预览顶栏。
</p>
</aside>
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* 呈现壳预览:主工作面 = 壳画布。
* 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动。
* 按「要同时看见什么」选壳;正文按 500~2000 字撑高滚动。
*/
import {
PRESENT_SHELL_IDS,
@@ -56,7 +56,7 @@ const TONES = [
const LENGTHS = [
{ id: "short", label: "短" },
{ id: "mid", label: "中 ·800" },
{ id: "long", label: "长 ·2500" },
{ id: "long", label: "长 ·2000" },
];
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
+331 -45
View File
@@ -18,8 +18,8 @@
--workspace-pad-x: clamp(24px, 4.5vw, 72px);
--workspace-pad-y: clamp(20px, 3vh, 40px);
--head-h: 48px;
--composer-min: 56px;
--composer-control-h: 40px;
--composer-min: 0px;
--composer-control-h: 36px;
--font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
--font-read: "Noto Serif SC", "Songti SC", "STSong", "Source Han Serif SC", Georgia, serif;
font-family: var(--font-ui);
@@ -201,7 +201,7 @@ body.rail-collapsed .panel-rail { align-items: stretch; }
/* composer 按内容自适应;中间区吃掉剩余高度,整页锁在视口内 */
.panel-main {
display: grid;
grid-template-rows: var(--head-h) minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
height: 100%;
@@ -435,7 +435,7 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle {
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
}
.coord-empty {
margin: 16px 8px;
@@ -443,6 +443,55 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle {
color: var(--muted);
text-align: center;
}
.coord-pack {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 4px 6px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
min-width: 0;
cursor: context-menu;
}
.coord-pack:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent-soft) 28%, var(--bg));
}
.coord-pack--pending {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
}
.coord-pack-head {
display: flex;
align-items: center;
min-height: 24px;
padding: 2px 28px 2px 8px;
}
.coord-pack-meta {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
}
.coord-pack--pending .coord-pack-meta {
color: var(--accent);
}
.coord-pack > .coord-line-menu {
top: 4px;
right: 4px;
}
.coord-pack:hover > .coord-line-menu,
.coord-pack:focus-within > .coord-line-menu {
opacity: 1;
}
.coord-pack .coord-line {
background: transparent;
padding: 6px 8px;
}
.coord-pack .coord-line:hover {
background: color-mix(in srgb, var(--accent-soft) 35%, transparent);
}
.coord-line {
position: relative;
display: flex;
@@ -565,6 +614,10 @@ body:not(.coord-rail-collapsed) .coord-rail-toggle {
color: var(--accent);
font-weight: 700;
}
.coord-context-request {
color: var(--text);
font-weight: 600;
}
.coord-trace-meta {
display: flex;
flex-wrap: wrap;
@@ -596,6 +649,14 @@ body.design-workspace .workspace-pending {
margin-bottom: 6px;
font-size: 12px;
}
.workspace-pending-head .msg-action {
margin-left: auto;
}
.msg-live-empty {
margin: 0;
color: var(--muted);
font-size: 13px;
}
body.is-reviewing .message-feed {
flex: 0 1 auto;
@@ -752,6 +813,33 @@ body.design-workspace[data-design-surface="answer"] .questions-card-host {
margin-top: 0;
padding: 0;
}
/* 答题/验收工作面已是 surface,询问不再套圆角卡片 */
body.design-workspace[data-design-surface="answer"] .questions-card-host.is-open:not([hidden]),
.workspace-review-ask .questions-card-host.is-open:not([hidden]) {
margin: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
body.design-workspace[data-design-surface="answer"] .qcard-body,
.workspace-review-ask .qcard-body {
padding-left: 0;
padding-right: 0;
}
body.design-workspace[data-design-surface="answer"] .qcard-top,
.workspace-review-ask .qcard-top,
body.design-workspace[data-design-surface="answer"] .questions-card-host.is-collapsed .qcard-top,
.workspace-review-ask .questions-card-host.is-collapsed .qcard-top {
padding-left: 0;
padding-right: 0;
}
body.design-workspace[data-design-surface="answer"] .qcard-skip,
.workspace-review-ask .qcard-skip,
body.design-workspace[data-design-surface="answer"] .qcard-pager,
.workspace-review-ask .qcard-pager {
right: 0;
}
body.design-workspace[data-design-surface="speak"] .workspace-surface-kicker {
color: var(--accent);
}
@@ -766,25 +854,23 @@ body.design-workspace[data-design-surface="review"][data-review-kind="opening"]
color: var(--accent);
}
/* 挂载追问嵌在产物验收面下方,与产物同一工作面 */
/* 挂载追问嵌在产物验收面下方:与产物同宽,随主柱动态适应 */
.workspace-review-ask {
flex: 0 0 auto;
margin-top: 0;
min-height: 0;
max-width: var(--workspace-max, 920px);
width: 100%;
align-self: center;
padding: 0 var(--workspace-pad-x, 24px) var(--workspace-pad-y, 16px);
max-width: none;
align-self: stretch;
padding: 8px var(--workspace-pad-x, 24px) var(--workspace-pad-y, 16px);
box-sizing: border-box;
border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.workspace-review-ask .questions-card-host {
padding-left: 0;
padding-right: 0;
border-top: none;
}
.workspace-review-ask .questions-card-host.is-open:not([hidden]) {
margin-top: 0;
}
body.design-workspace .ws-creation-flow h4 { display: none; }
body.design-workspace .flow-brief {
@@ -1058,6 +1144,32 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
border-radius: 6px;
min-width: 0;
}
.explorer-row.has-twist {
grid-template-columns: 16px minmax(0, 1fr) auto;
}
.explorer-twist {
grid-column: 1;
grid-row: 1 / span 2;
align-self: center;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
line-height: 1;
color: var(--muted);
user-select: none;
}
.explorer-row.has-twist .explorer-main { grid-column: 2; }
.explorer-row.has-twist .explorer-del { grid-column: 3; }
.explorer-row.child {
padding-left: 28px;
min-height: 28px;
padding-top: 5px;
padding-bottom: 5px;
}
.explorer-row.child .explorer-name { font-size: 12px; font-weight: 400; }
.explorer-row:hover { background: var(--bg); }
.explorer-row.active { background: var(--accent-soft); }
.explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; }
@@ -1119,7 +1231,7 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
color: var(--muted); letter-spacing: 0.04em;
}
.explorer-hint {
margin: 4px 8px; font-size: 11px; color: var(--muted); line-height: 1.4;
margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4;
}
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
@@ -1214,6 +1326,9 @@ body.rail-collapsed .message-feed {
font-size: 11px;
flex-wrap: wrap;
}
.msg-head [data-act="retry_run"] {
margin-left: auto;
}
.msg-foot {
display: flex;
gap: 8px;
@@ -1339,6 +1454,41 @@ body.rail-collapsed .message-feed {
max-height: 240px;
overflow: auto;
}
.msg-play-process-row {
margin: 4px 0;
}
.msg-play-process {
font-size: 12px;
color: var(--muted);
border: 1px dashed var(--border);
border-radius: 6px;
background: #fafafa;
}
.msg-play-process > summary {
cursor: pointer;
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px;
user-select: none;
list-style: none;
}
.msg-play-process > summary::-webkit-details-marker { display: none; }
.msg-play-process > summary::before {
content: "▸";
font-size: 10px;
color: var(--muted);
}
.msg-play-process[open] > summary::before { content: "▾"; }
.msg-play-process .msg-tag {
font-weight: 600;
color: var(--muted);
}
.msg-play-process .msg-body {
padding: 0 10px 10px;
max-height: 320px;
overflow: auto;
}
.msg-live-indicator {
margin-left: 8px;
@@ -1519,7 +1669,7 @@ body.rail-collapsed .message-feed {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 40;
z-index: 80;
min-width: 132px;
padding: 4px;
border: 1px solid var(--border);
@@ -1530,13 +1680,28 @@ body.rail-collapsed .message-feed {
flex-direction: column;
gap: 1px;
}
.composer .more-menu-panel {
top: auto;
bottom: calc(100% + 4px);
}
.more-menu-item {
display: block;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
text-decoration: none;
text-align: left;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.more-menu-item[hidden] { display: none !important; }
.more-menu-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.more-menu-item:hover {
background: var(--bg);
@@ -1567,27 +1732,50 @@ body.rail-collapsed .message-feed {
.msg-q-list li { margin: 0.35rem 0; }
.msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; }
/* 游玩:阅读柱略抬权重 */
/* 游玩:每轮终稿仍是气泡(方便扫上下文),正文在气泡里撑高 */
body[data-lifecycle="play"] .message-feed {
padding: 14px 18px 18px;
gap: 14px;
padding: 16px 18px 22px;
gap: 16px;
}
body[data-lifecycle="play"] .msg-assistant-row .msg-bubble {
width: min(100%, 44rem);
max-width: 100%;
border-color: color-mix(in srgb, var(--border) 70%, transparent);
box-shadow: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
body[data-lifecycle="play"] .msg-assistant-row .msg-head {
padding-top: 6px;
}
body[data-lifecycle="play"] .present-shell {
border-radius: 10px;
}
body[data-lifecycle="play"] .msg-user-row .msg-bubble {
width: min(100%, 28rem);
max-width: 90%;
}
body[data-lifecycle="play"] .msg-play-final .msg-bubble {
width: min(100%, 46rem);
}
body[data-lifecycle="play"] .msg-play-final .present-shell {
border: none;
box-shadow: none;
background: transparent;
padding: 0;
border-radius: 0;
}
body[data-lifecycle="play"] .msg-play-final .present-shell-badge {
display: none;
}
body[data-lifecycle="play"] .msg-play-final .present-stage .present-region--body .present-region-body,
body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body {
font-size: 18.5px;
line-height: 1.88;
max-width: none;
max-height: none;
overflow: visible;
}
body[data-lifecycle="play"] .msg-play-final .present-shell--chat_monitor .present-stage {
padding: 0;
border: none;
background: transparent;
}
/* 产物紧凑卡:横向主轴,预览一行 */
.artifact-compact-list {
@@ -2455,7 +2643,8 @@ body.design-workspace .af-mosaic {
color: var(--text);
line-height: 1.45;
}
.propose-sync-hint {
.propose-sync-hint,
.propose-plan-hint {
margin: 0;
font-size: 12px;
color: var(--muted);
@@ -2544,6 +2733,9 @@ body.design-workspace .af-mosaic {
font-size: 12px;
font-weight: 400;
}
.flow-occ-revise {
color: var(--text, #222);
}
.flow-order {
grid-row: 1 / -1;
align-self: center;
@@ -2685,10 +2877,9 @@ body.design-workspace .af-mosaic {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #2a6) 20%, transparent);
}
/* 询问卡:host 本身即卡片(不再套内层 .qcard) */
/* 询问卡:对话流里 host 即卡片;创作工作面上剥壳,见上方 flatten 规则 */
.questions-card-host {
--qcard-max: 48vh;
--qcard-max-expanded: 82vh;
flex: 0 1 auto;
width: 100%;
box-sizing: border-box;
@@ -2710,16 +2901,17 @@ body.design-workspace .af-mosaic {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface, #fff);
transition: max-height 0.22s ease;
transition: max-height 0.18s ease;
}
.questions-card-host.is-open.is-expanded:not([hidden]) {
max-height: var(--qcard-max-expanded);
.questions-card-host.is-open.is-collapsed:not([hidden]) {
max-height: none;
}
.qcard-top {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 18px;
margin: 0;
padding: 4px 12px 0;
@@ -2734,18 +2926,40 @@ body.design-workspace .af-mosaic {
.qcard-top-grip {
width: 28px;
height: 3px;
flex-shrink: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--muted, #888) 35%, transparent);
}
.questions-card-host.is-expanded .qcard-top {
min-height: 22px;
padding-top: 6px;
}
.questions-card-host.is-expanded .qcard-top:hover .qcard-top-grip {
.qcard-top:hover .qcard-top-grip {
background: color-mix(in srgb, var(--muted, #888) 55%, transparent);
}
.questions-card-host:not(.is-expanded) .qcard-body {
cursor: pointer;
.qcard-collapsed-meta {
display: none;
min-width: 0;
flex: 1;
font-size: 12px;
font-weight: 550;
line-height: 1.35;
color: var(--text);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.questions-card-host.is-collapsed .qcard-top {
min-height: 44px;
padding: 0 12px;
justify-content: flex-start;
}
.questions-card-host.is-collapsed .qcard-top:hover .qcard-top-grip {
background: color-mix(in srgb, var(--muted, #888) 55%, transparent);
}
.questions-card-host.is-collapsed .qcard-collapsed-meta {
display: block;
}
.questions-card-host.is-collapsed .qcard-body,
.questions-card-host.is-collapsed .qcard-float {
display: none;
}
/* 悬浮控件层:不占文档流高度 */
.qcard-float {
@@ -3472,6 +3686,7 @@ body.design-workspace .af-mosaic {
border: none; background: transparent; padding: 8px 12px;
font-size: 13px; cursor: pointer; border-radius: 4px;
}
.context-menu-item[hidden] { display: none; }
.context-menu-item:hover { background: var(--bg); }
.context-menu-separator-before { border-top: 1px solid var(--border); }
.context-menu-danger { color: var(--danger); }
@@ -3491,18 +3706,46 @@ body.design-workspace .af-mosaic {
.timeline-item { padding: 6px 0; border-bottom: 1px solid var(--border); }
.timeline-empty { color: var(--muted); }
/* Composer — 单层输入壳;底栏本身不再套第二层卡片 */
/* Composer — 单行:状态 / 输入 / 更多;菜单向上弹出不被裁切 */
.composer {
border-top: 1px solid var(--border);
padding: 10px var(--workspace-pad-x, 16px) 12px;
padding: 6px var(--workspace-pad-x, 16px) 8px;
min-height: var(--composer-min);
max-height: min(50vh, 420px);
overflow: hidden;
overflow: visible;
background: var(--bg);
flex-shrink: 0;
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
justify-content: flex-end;
flex-direction: row;
align-items: flex-end;
gap: 8px;
}
.composer > .status {
flex: 0 0 auto;
align-self: center;
min-width: 0;
}
.composer:has(.composer-mode-chip) > .status {
display: none;
}
#composer-main {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
max-height: min(50vh, 420px);
overflow: auto;
}
.composer > .more-menu {
flex: 0 0 auto;
align-self: flex-end;
margin-bottom: 2px;
}
.composer .more-menu-sum {
height: calc(var(--composer-control-h) - 8px);
display: inline-flex;
align-items: center;
box-sizing: border-box;
}
/* 唯一描边:输入 + 操作同框 */
.composer-input-shell {
@@ -3584,7 +3827,7 @@ body.design-workspace .af-mosaic {
overflow-y: hidden;
border: none;
border-radius: 0;
padding: 8px 4px;
padding: 6px 4px;
line-height: 1.5;
background: transparent;
box-shadow: none;
@@ -3603,7 +3846,16 @@ body.design-workspace .af-mosaic {
margin-right: 2px;
}
.composer-waiting, .composer-idle {
padding: 12px; text-align: center; color: var(--muted); font-size: 13px;
padding: 6px 0; text-align: left; color: var(--muted); font-size: 13px;
}
.composer-waiting {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.composer-waiting .btn {
flex-shrink: 0;
}
.composer-hint { margin: 0 0 8px; font-size: 12px; color: var(--muted); }
.composer-form {
@@ -3656,7 +3908,7 @@ body.design-workspace .af-mosaic {
overflow-y: hidden;
border: none;
border-radius: 0;
padding: 8px 4px;
padding: 6px 4px;
line-height: 1.5;
background: transparent;
field-sizing: content;
@@ -3666,7 +3918,7 @@ body.design-workspace .af-mosaic {
border: none;
box-shadow: none;
}
.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.composer-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 0; }
.composer-actions .btn-primary { flex: 1; }
/* Intake (from intake-ui.js) */
@@ -3729,7 +3981,7 @@ body.design-workspace .af-mosaic {
}
/* —— 游玩呈现壳 present.v1 ——
* 主工作面 = body(随 600~3000 字撑高)。
* 主工作面 = body(随 500~2000 字撑高)。
* 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。
*/
.present-shell {
@@ -3793,7 +4045,7 @@ body.design-workspace .af-mosaic {
white-space: pre-wrap;
font-size: 14px;
}
/* P0:正文舞台 —— 随字数伸展(600~3000 靠外层滚动,不塞进小框) */
/* P0:正文舞台 —— 随字数伸展(500~2000 靠外层滚动,不塞进小框) */
.present-stage {
flex: 1 1 auto;
min-height: 0;
@@ -4085,3 +4337,37 @@ body.design-workspace .af-mosaic {
color: #e7e5e4;
background: #3f3f46;
}
.opening-setup-body {
display: grid;
gap: 12px;
}
.opening-picker {
display: grid;
gap: 8px;
}
.opening-picker-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.opening-picker-btn {
font: inherit;
font-size: 12px;
padding: 4px 10px;
border: 1px solid var(--border);
background: var(--bg-elev, transparent);
color: inherit;
border-radius: 6px;
cursor: pointer;
}
.opening-picker-btn:hover {
border-color: var(--accent);
}
.opening-picker-pos {
font-size: 12px;
color: var(--muted, #888);
min-width: 4.5em;
text-align: center;
}