引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/卡片。
|
||||
|
||||
@@ -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` | 已写 |
|
||||
|
||||
@@ -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 建议为人设** |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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. 第一版导入策略
|
||||
|
||||
第一版导入器只做保守转换:
|
||||
|
||||
@@ -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
@@ -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` | 说话 | 按意见修改 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 集 + 正文组成 + 叙事指南 + 变量设计上现写开场,并钉同真相初值。
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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} 道可选追问`
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
|
||||
@@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog(
|
||||
id: "declare-ready",
|
||||
stage: "design",
|
||||
label: "实例就绪",
|
||||
purpose: "Worker 集已 accept 即可进游玩;若声明了开局,建议先验收开场白(非强制锁定)。",
|
||||
purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。",
|
||||
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("当前没有可重试的执行");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("缺少本轮输入");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -12,6 +12,7 @@ import {
|
||||
parsePresentDoc,
|
||||
presentFromPlain,
|
||||
renderPresentShellHtml,
|
||||
stripPresentSourceFences,
|
||||
} from "./present-shells.js";
|
||||
|
||||
const HIDE_KINDS = new Set(["worker_stub"]);
|
||||
@@ -57,6 +58,10 @@ function esc(s) {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderThinkingBlock(thinking, { open = false } = {}) {
|
||||
const text = (thinking ?? "").trim();
|
||||
if (!text) return "";
|
||||
@@ -66,23 +71,39 @@ function renderThinkingBlock(thinking, { open = false } = {}) {
|
||||
</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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user