完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。

- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。
- 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。
- 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。
- 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。
- 同步世界模拟器模块提示、编排文档与相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 01:28:12 +08:00
parent 20938b04d7
commit ec474cb946
56 changed files with 7460 additions and 1372 deletions

View File

@@ -4,7 +4,7 @@
| 产物/技能 | 美化程度 | 渲染入口 | | 产物/技能 | 美化程度 | 渲染入口 |
|-----------|----------|----------| |-----------|----------|----------|
| 美学纲领与交互范式 | **专用 mosaic**(设定逻辑 / 交互范式 / 美学纲领 | `renderSpecialtyBodyHtml` → aesthetics | | 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式/ 右诊断(设定逻辑 | `renderSpecialtyBodyHtml` → aesthetics |
| 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` | | 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` | | 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |

View File

@@ -356,7 +356,8 @@ task 必须写:禁止重复同一开场;在首答上补洞。
## 4.8 追问(`probe`)通用要求 ## 4.8 追问(`probe`)通用要求
- 一轮 askUser **12** 点。 - 一轮 **12** 点,只写进产物 `追问`(建议选项 + 示例)
- 程序会把 `追问` 挂到询问卡;**禁止**同一问再抄一份顶层 `askUser``askUser` 留 `null`)。
- 优先:可直接采用或微调的完整句选项 / 短场景。 - 优先:可直接采用或微调的完整句选项 / 短场景。
- 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。 - 能推断则先写入再复述;只问影响本步核心且不能瞎填的点。
- 依赖产物已钉死的内容禁止重问。 - 依赖产物已钉死的内容禁止重问。
@@ -397,7 +398,7 @@ task 必须写:禁止重复同一开场;在首答上补洞。
|----|------| |----|------|
| `正文` | **产物主体**。技能自定内部结构(如美学步=设定逻辑 + 交互范式 + 美学纲领);须含「详细各个方面」。禁止整份只交散文。 | | `正文` | **产物主体**。技能自定内部结构(如美学步=设定逻辑 + 交互范式 + 美学纲领);须含「详细各个方面」。禁止整份只交散文。 |
| `自评` | **自评评分**。`维度[]` 名目按技能定(美学步常用:交互范式 / 美学纲领 / 整体协调);**`分数` 为 010 十分制**(可一位小数;高质约 810可含 `薄弱点`。禁止用 0100 百分数。 | | `自评` | **自评评分**。`维度[]` 名目按技能定(美学步常用:交互范式 / 美学纲领 / 整体协调);**`分数` 为 010 十分制**(可一位小数;高质约 810可含 `薄弱点`。禁止用 0100 百分数。 |
| `追问` | **示例 + 建议选项**。给用户的下一问;`题目` 可空数组。与程序 askUser 可并存,但产物里要留结构化题面。 | | `追问` | **示例 + 建议选项**。给用户的下一问(程序挂询问卡)`题目` 可空数组。禁止同一问再写入顶层 `askUser`。 |
| 规则 | 说明 | | 规则 | 说明 |
|------|------| |------|------|

View File

@@ -38,10 +38,20 @@
| `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` | | `schema` / `技能` / `brief` / `mount` / `稳变` | 索引与挂载;不写 `order` |
| `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) | | `正文` | **产物主体**(技能自定内部维度,但必须有「各个方面」) |
| `自评` | **自评评分**`维度[]` + 可选薄弱点;维度名按技能) | | `自评` | **自评评分**`维度[]` + 可选薄弱点;维度名按技能) |
| `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []` | | `追问` | **导语 + 题目**(建议选项 / 示例);可无题则 `题目: []`。程序挂询问卡;不要再抄一份 `askUser` |
| `开放问题` | 未结构化的残项(可选) | | `开放问题` | 未结构化的残项(可选) |
美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。 美学步正文:`设定逻辑` + `交互范式` + `美学纲领`(见 `aesthetics-interaction`)。
**验收卡排版约定(美学步已落地,其它节点逐步对齐)**
| 栏 | 放什么 | 美学步对应 |
|----|--------|------------|
| **左 · 产物** | 最终希望插入游玩上下文的契约正文 | `美学纲领``交互范式` |
| **右 · 评估** | 诊断、完备度、待探、区域化等给人验收用的 | `设定逻辑`(参与/内容维度上的结论·依据·完备度) |
外壳另有 `自评`(顶栏分数 pill`追问`(询问卡)。
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。 实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。 生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。

View File

@@ -10,14 +10,25 @@
```text ```text
固定壳(本仓库预置,七种) 固定壳(本仓库预置,七种)
创作期:选一个 shell_id 按「要同时看见什么」选 shell_id(不是按好看)
→ 只改允许的微调轴字段、显示名、开关、tone_chrome… → 只改允许的微调轴
→ 禁止改分区骨架、禁止发明第八种布局 → 禁止改分区骨架、禁止发明第八种布局
游玩期:往壳区域灌数present.v1 / 投影 / settlement 游玩期:往壳区域灌数;正文随 600~3000 字撑高滚动
``` ```
「正文组成」= **壳适配单**,不是 UI 创意稿。 选壳依据(同时可见关系):
用户看见的差异应来自**同壳不同绑定**,而不是每局一套新布局。
| 同时要看见 | 壳 |
|------------|-----|
| 只要正文 | `prose` |
| 正文 + 扫状态 | `chat_monitor` |
| 大正文 + 弱状态 | `spotlight` |
| 场面 + 交互物 + 行动 | `turn_panel` |
| 正文 ‖ 线索对照 | `split_board` |
| 局面 + 选项 | `choice_dock` |
| 章正文 + 进度 | `chapter_reader` |
预览页 `/shells.html`:主工作面是壳画布;侧栏只换壳;长度/语气在顶栏。
## 2. 信息层级与长文容量 ## 2. 信息层级与长文容量

View File

@@ -87,7 +87,7 @@
| `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 | | `设计.worker集` | **运行规格** | 剧本(旧,规格部分) | 可进游玩的声明截面。 |
| Blackboard | **黑板** | 上下文板 | — | | Blackboard | **黑板** | 上下文板 | — |
| Artifact | **产物** | — | 待验收输出。 | | Artifact | **产物** | — | 待验收输出。 |
| Accept / Review | **验收** / **接受** | 人工审批 | 按钮用「接受」;阶段说明用「验收」。 | | Accept / Review | **验收** / **接受** | 人工审批 | 技能产物按钮用「接受」;工作流计划用「确认编排」;开场用「选定此开场」。阶段说明:技能步「验收」,编排「核对」。 |
| Intake | **需求描述** | 启动填空 | — | | Intake | **需求描述** | 启动填空 | — |
| Burst | **本轮调度** | — | 少对用户说 burst。 | | Burst | **本轮调度** | — | 少对用户说 burst。 |
| Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 | | Questions card | **询问卡** | Questions | 详见 `ui-design.md` §8。 |
@@ -177,13 +177,15 @@
## 7. 等待态 / 焦点(对用户) ## 7. 等待态 / 焦点(对用户)
| `waitingReason.kind` | 状态摘要(短) | 焦点动作 | | `waitingReason.kind` | 状态摘要(短) | 焦点动作 / 底栏主按钮 |
|----------------------|----------------|----------| |----------------------|----------------|----------|
| `intake` | 描述需求 | 描述创作需求 | | `intake` | 描述需求 | 发送 |
| `input` | 补充说明 | 补充说明 / 回答追问 | | `input` | 补充说明 | 发送 / 补充并发送 |
| `worker_questions` | 回答提问 | 回答 · {中文执行单元名} | | `worker_questions` | 答题 | 补充并发送 |
| `approve_step` | 确认执行 | 建议调用 {中文名} | | `approve_step` | 确认 | 同意并开始 |
| `review_artifact` | 验收产物 | 验收产物 | | `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
| `next_intent` | 说话 | 继续(可留空) |
| `revision` | 说话 | 按意见修改 |
--- ---

View File

@@ -28,7 +28,7 @@
| 块 id | 必填 | 用途 | | 块 id | 必填 | 用途 |
|-------|------|------| |-------|------|------|
| `meta` | 建议 | YAMLname / id / artifact / declaration / when / when_not / boundary | | `meta` | 建议 | YAMLname / id / artifact / declaration / when / when_not / boundary |
| `opening` | 可选 | **默认问题**正文;程序发给用户,不经 LLM | | `opening` | 可选 | **默认问题**正文。开局:配方 `steps` 已预置进「设计.创作流程」,用户首句即坐在 DAG 第一步;若「用户.需求」已有内容,程序**跳过**再抛 opening直接进本步 LLM |
| `task` | 是 | 本步任务与验收边界 | | `task` | 是 | 本步任务与验收边界 |
| `principles` | 建议 | 原则 | | `principles` | 建议 | 原则 |
| `probe` | 建议 | 追问策略 | | `probe` | 建议 | 追问策略 |
@@ -104,13 +104,24 @@ declaration: …
配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。 配方**不要**重复罗列各能力调用条件;那是能力 meta 的职责。旧字段 `hint` 仍可读作兜底。
### 默认问题节奏(通用) ### 节点循环(通用)
```text ```text
程序发 opening → 用户首答 → LLMopening + 首答 + 切割后的方法块 + 依赖) 开局选配方 → 坐到配方预置的第一个节点
→ 节点 opening → 用户首批输入
→ 节点要求 + 用户输入 → 产物 + 追问
→ 用户选选项 / 写意见 →「按意见修改」
(产物 + 追问 + 选项 + 意见 + 节点要求一并重跑)→ 新产物
→ 循环直到「接受」
→「下一步想写什么」(可留空,交给工作流计划 DAG
→ 展示下一节点并确认开干(可在此钉编排参数,如生成规则的 target
→ 下一节点 opening / design-step …
直到创作收口
``` ```
有编排参数的能力:先由 design-flow askUser 钉 params → 用户认可 DAG → design-step 注入【本步参数】执行;勿把「生成什么」推迟到执行期 「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter接受已选追问时 Enter按意见修改。点按钮仍只做按钮自己的事
有编排参数的能力:可在确认开干时钉齐 params勿把「生成什么」拖到执行中途才问。
--- ---

View File

@@ -12,6 +12,7 @@
"phase-demo": "tsx src/cli/phase-demo.ts", "phase-demo": "tsx src/cli/phase-demo.ts",
"phase-script": "tsx src/cli/phase-demo.ts --script --auto", "phase-script": "tsx src/cli/phase-demo.ts --script --auto",
"web": "tsx src/server/web-server.ts", "web": "tsx src/server/web-server.ts",
"web:watch": "tsx watch --clear-screen=false --include \"./src/**/*.ts\" --include \"./skills/**\" src/server/web-server.ts",
"electron": "electron ." "electron": "electron ."
}, },
"devDependencies": { "devDependencies": {

View File

@@ -54,8 +54,10 @@ feeds: design_only
业务字段全部放在「正文」内;须含 schema / 技能 / brief建议填 mount 与稳变(本步不写数字 order 业务字段全部放在「正文」内;须含 schema / 技能 / brief建议填 mount 与稳变(本步不写数字 order
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问不要先交「半份交互」再开「半份美学」。 一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问不要先交「半份交互」再开「半份美学」。
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
若程序已发出默认问题:用户首答在「用户.worker答复」开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。 若程序已发出默认问题:用户首答在「用户.worker答复」开场白在「创作.能力开场白」。禁止重复同一开场;在首答基础上补洞、追问、产出。
开局通常已由配方写入「设计.创作流程」第一步;若「用户.需求」已有首句:程序会跳过默认问题——首句即本步对话,只补真正缺口。
``` ```
## principles ## principles
@@ -74,9 +76,11 @@ feeds: design_only
## probe ## probe
```probe ```probe
用户首句或黑板已有的直接采纳。一次 askUser 12 点,优先具体选项/短场景 用户首句或黑板已有的直接采纳。一次只在产物「追问」写 12 点(建议选项 + 示例);顶层 askUser 留 null不要同一问写两遍
已有一轮追问答复或核心体验+权限边界已可执行:`追问.题目` 用 `[]`,让用户验收。禁止这轮问交互、下轮问美学地来回打转。
【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。 【默认问题已覆盖】变造(原型+变点)、代入与否、最想感受到的核心。首答后按缺口补,勿重问已答清的。
若开场已跳过(用户.需求已有首句):把首句当默认问题的答复,只补真正缺口。
【参与 · 结构性】 【参与 · 结构性】
- 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色 - 用户与 <user>:代入程度 / 情感距离 / 控制期待 / 多角色
@@ -205,7 +209,7 @@ feeds: design_only
2. **公共三段**`正文`=产物主体;`自评`=完备度打分(**分数 010 十分制,禁止百分数**`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。 2. **公共三段**`正文`=产物主体;`自评`=完备度打分(**分数 010 十分制,禁止百分数**`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
3. **本技正文三块**`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。 3. **本技正文三块**`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order``稳变` 定稿后多为 `stable` 4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order``稳变` 定稿后多为 `stable`
5. `追问.题目` 一次宜 13 题;有建议选项;允许「其它」。已答清的不要再问。 5. `追问.题目` 一次宜 13 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」askUser 为 null。
6. summary`美学纲领与交互范式 · {brief 缩略}` 6. summary`美学纲领与交互范式 · {brief 缩略}`
## checklist ## checklist
@@ -216,7 +220,7 @@ feeds: design_only
- [ ] 未写 order、未输出执行单元列表 - [ ] 未写 order、未输出执行单元列表
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定? - [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
- [ ] 交互范式权限与美学纲领互相保护、不矛盾? - [ ] 交互范式权限与美学纲领互相保护、不矛盾?
- [ ] 追问只打薄弱点,带建议选项? - [ ] 追问只打真正薄弱点,带建议选项?已答过则题目为空、准备验收?
- [ ] 有没有为「完整」强行填充百科或偏离用户核心? - [ ] 有没有为「完整」强行填充百科或偏离用户核心?
``` ```

View File

@@ -46,11 +46,11 @@ modules:
- key: target - key: target
label: 生成对象 label: 生成对象
required: true required: true
hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它 hint: 本轮为哪一类内容建规则;编排时 askUser 给选项+其它;用户改对象时必须同步改 rule_id 与步骤 id
- key: rule_id - key: rule_id
label: 规则 id label: 规则 id
required: false required: false
hint: 建议英文 kebab-case;可执行时再最终钉死 hint: 英文 kebab-case,须与 target 对应女租客→female-tenants丧尸→zombies改对象时禁止沿用旧 id
- key: lifecycle_intent - key: lifecycle_intent
label: 生命周期意图 label: 生命周期意图
required: false required: false

View File

@@ -68,6 +68,7 @@ feeds: gm,auditor
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。 4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。 5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
6. `rule_id`params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 6. `rule_id`params 已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与 params 不一致(用户中途改了对象),以最新对象为准并重钉对应英文 rule_id禁止沿用旧 id。
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。 7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。 若程序已发默认问题:禁止重复开场;在首答上补洞。

View File

@@ -32,6 +32,17 @@ feeds: gm
## opening ## opening
```opening ```opening
核心体验已有方向时,用几句话想想:它具体靠什么撑住?(想到什么写什么,不必整齐;点名即可,不必论证来源或补起源史。)
1. 这体验主要靠哪些东西成立?
角色的某一面、一段关系、世界条件、规则或情境……用你自己的词点名。
例:丈夫会把异常证据合理化;力量越用越侵蚀自我;村里每年必须献祭才能过冬。
2. 拿掉或改弱哪一样,核心感觉会明显变味甚至垮掉?
一句话即可——只点「缺了就不对」的,装饰和以后再展开的先别列。
3. 若已有具体画面或作品参照,它主要指哪一切面在撑体验?
例:信息差、无法撤销的前提、身份代价、循环反馈……不必复述整部设定。
``` ```
## task ## task

View File

@@ -34,14 +34,14 @@ feeds: narrator
```opening ```opening
本局在固定呈现壳上做适配(不是设计新界面)。 本局在固定呈现壳上做适配(不是设计新界面)。
0. 七选一壳(主面积给谁) 0. 按「要同时看见什么」七选一:
· prose — 纯散文(正文≈全屏) · prose — 只要正文
· chat_monitor — 对话 + 顶栏芯片 · chat_monitor — 正文 + 扫状态
· spotlight — 场面主视(正文最大) · spotlight — 大正文 + 弱状态
· turn_panel — 回合面板(场面+侧栏交互) · turn_panel — 场面 + 交互物 + 行动
· split_board — 双栏(左故事·右线索 · split_board — 正文 ‖ 线索
· choice_dock — 选择坞(底部大行动区) · choice_dock — 局面 + 选项
· chapter_reader — 章节阅读 · chapter_reader — 章正文 + 进度
1. 要微调什么监控字段、块称呼、要不要建议行动、tone_chrome… 1. 要微调什么监控字段、块称呼、要不要建议行动、tone_chrome…
2. 有没有隐藏段给模型/程序维护变量? 2. 有没有隐藏段给模型/程序维护变量?

View File

@@ -44,90 +44,9 @@ uiPrompt: |
# 世界模拟器 · 总管 # 世界模拟器 · 总管
作者清单见 `docs/world-simulator-modules.md` 你只负责读取 `设计.创作流程` DAG并选择下一个创作节点
你是 **总管**:负责 design / play 的 **调度**,不直接写正文。 - 没有 DAG 或 DAG 需要追加节点:`design-flow`
禁止默认把一切做成「世界模拟」;按用户意图正推最小能力组合。 - DAG 已给出下一个可执行节点:`design-step`
## 导演 · 能力 · 剧本 节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。
```text
用户手动选【导演】recipes/ 【能力】池modules/
世界模拟器 / 扩写助手 … 美学纲领与交互范式 / …
│ │
└──────────── design-flow ───────────┘
以用户所选为起点 → 排出近期增量 DAG可追加、可同能力多次
→ 产出【剧本】流程(设计.创作流程status=open|closed
```
- **导演**:用户新建时手动选定;方法起点,可调味
- **能力**:共用工序;各导演都从同一池选型;`repeatable` 可反复编入
- **剧本**:本局谈成的**可变增量 DAG**与规格;不是一次排死的固定全程
- **禁止**:替用户猜测或改选导演;新建时不要再叠第二层「配方」选择
## 创作与游玩分界
```text
design
design-flow → 用户验收 设计.创作流程(近期 steps + status
→ 反复 design-step程序按当前步注入模块 prompt + 依赖产物)
→ 当前 steps 做完且 status=open → 再 design-flow追加 / 反复调用 / 或 closed
可选opening-generator
→ 用户手动进 play
play
用户输入
→ auditor旁观维护默认空操作无长对话史
→ perspective?(仅强信息隔离)
→ gm / world-simulator主世界层settlement.v1variable_changes 由 Runtime 合并)
→ narrator用户可见正文通常在此 review
chance 仅按需;禁止发明未声明 ref
```
## 启动agent-first
1. 首屏 `uiPrompt`
2. 用户首句 → `用户.需求` → 总管 tool loop
3. 尚无已验收流程 → `run_worker(design-flow)`
4. 流程已有未完成步骤 → `run_worker(design-step)`
5. 当前步骤都验收完但 `status=open` → 再 `design-flow`(扩步或收口)
6. `status=closed` 且步骤完成、终稿可用后若需开局 → `opening-generator`
## Skill 注册表
| id | 说明 |
|----|------|
| design-flow | 以用户已选导演为起点,编排/增量修订剧本 DAG |
| design-step | 执行流程中当前一步(模块由程序注入) |
| opening-generator | 开场白(创作末尾可选) |
`design-core` / `design-fixed` / `design-worker` / `design-refine` **已废弃**,禁止调度。
## 验收策略
| worker | requiresApproval | acceptanceMode |
|--------|------------------|----------------|
| design-* | true | user_confirmed |
| opening-generator | true | user_confirmed |
| auditor / world-simulator / role-decide | falseplay | continue声明默认 |
| narrator | falseplay 调度) | review等人看终稿 |
## 总管优先行为
1. 有需求、尚无已验收 `设计.创作流程``design-flow`
2. 流程已有、存在未验收步骤 → `design-step`
3. 已列步骤全验收但 `status=open``design-flow`(追加反复步或设 closed
4. `waiting_user(review_artifact)` → 引导验收
5. reject → 收修订 → 重跑同一 worker含修订流程 = 再调味)
6. 终稿(含 `设计.worker集`)已 accept 且需开局 → `opening-generator`
7. **play**:按 `play_slots``auditor → perspective? → gm → narrator``pendingSideEffectWorkers` 优先
(旁观与主世界层怎么配合:见 `docs/play-dm-auditor.md`——旁观看门补表DM 断事,勿默认多轮传话)
## 禁用行为
- 调度已废弃的 design-core / design-fixed / design-worker / design-refine
- 跳过 design-flow 直接 design-step无流程时
- 一次 design-flow 排死全程固定长链(应增量)
- 调度声明未列出的 play ref
- Agent 挑选模型
- play 中把旁观维护当正文作者,或跳过 gm 直接 narrator

View File

@@ -1,7 +1,7 @@
# 扩写助手 · 配方 # 扩写助手 · 配方
# 写方法论:适用、核心思路、设计流程、原则。 # 写方法论:适用、核心思路、设计流程、原则。
# 各能力何时用 / 不用 → 读能力 meta编排器会注入勿在此重复。 # 各能力何时用 / 不用 → 读能力 meta编排器会注入勿在此重复。
# steps = 近期起点不是固定全程 DAG。 # steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG
when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验 when: 大纲/分段扩写、写手统筹、先纲后章、成稿向助手、长文/爽文类体验

View File

@@ -1,7 +1,7 @@
# 世界模拟器 · 配方 # 世界模拟器 · 配方
# 写方法论:适用、核心思路、设计流程、原则。 # 写方法论:适用、核心思路、设计流程、原则。
# 各技能何时用 / 不用 → 读技能 meta编排器会注入勿在此重复。 # 各技能何时用 / 不用 → 读技能 meta编排器会注入勿在此重复。
# steps = 近期起点不是固定全程 DAG。 # steps = 近期起点;选中本配方时程序写入「设计.创作流程」,开局即坐在第一步(不是固定全程 DAG
when: 回合互动、世界推进、角色扮演、沉浸推演类体验 when: 回合互动、世界推进、角色扮演、沉浸推演类体验

View File

@@ -44,9 +44,12 @@ contextSegments:
上下文里会有: 上下文里会有:
1. **用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方 1. **流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。
2. **能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入 2. **用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
3. **已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 3. **能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次编入
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id只加新步。
## 增量 DAG核心 ## 增量 DAG核心
@@ -56,9 +59,9 @@ contextSegments:
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。 - **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。 - **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
- 有「编排参数」声明的能力:写入 DAG 前必须钉齐 **必填 params** - 有「编排参数」声明的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params用户在「下一步」意向后、点同意开始时补齐
- 参数不明时:**先 askUser**(优先 options + 允许其它),再产出流程;禁止把「生成什么 / 调用哪个规则」推迟到 design-step。 - 参数不明时:可先产出带空 params 的步骤,或 askUser;禁止把「生成什么 / 调用哪个规则」到 design-step 执行中途
- 用户先认可带参数的 DAG 雏形,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 - 用户先认可**下一节点**(含参数),再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact增量补全。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2` 同能力**可以**多次出现(尤其〔可反复〕):每次一次调用、一次验收、产物写入同一 artifact增量补全。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`
@@ -105,13 +108,20 @@ contextSegments:
5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写 5. `params` = 本步调用参数(对象)。能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写
6. `status``"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成) 6. `status``"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params 7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
8. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满 8. 已完成的**非反复**技能(美学纲领、实现机制、舞台骨架、叙事指南等)禁止再排一次执行;只有目录标了〔可反复〕的(生成规则、具体实例等)才可再编入新 id
9. **禁止**把「交互」与「美学」拆成两步 9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
10. **禁止**在本步写能力正文、自由 Worker 列表、表结构 10. **禁止**把「交互」与「美学」拆成两步
11. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order 11. **禁止**在本步写能力正文、自由 Worker 列表、表结构
12. 参数或选型不够时,用 askUser 问 12 点(**优先带 options并允许其它** 12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」。挂谁可早、排第几须晚,勿让中段技能写死 order
13. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选 13. 参数或选型不够时,用 askUser 问 12 点(**优先带 options并允许其它**)。
14. `summary``流程 · N 步 · open|closed · …` 只许问**排布层**的事:下一步排哪个能力、某步的必填 params 取什么值。
**禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活;
已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」)
14. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
15. `summary``流程 · N 步 · open|closed · …`
16. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时,须同步改
`params.target``params.rule_id`(对应英文 kebab-case`zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。
**禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。
## 自检 ## 自检
@@ -123,5 +133,7 @@ contextSegments:
- 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳? - 有编排参数的步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
- depends_on 是否都指向更靠前的步骤 id - depends_on 是否都指向更靠前的步骤 id
- 已验收 id 是否都还在? - 已验收 id 是否都还在?
- 【流程进度】里已完成的非反复技能,是否误当成新规划又排了一次?
- askUser 是否只问了排哪步 / 填哪个参数,而不是体验偏好等正文问题?
- 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步? - 需要反复补同类内容时,是否用了新 id + 新 params 追加而非改写旧步?
- 收成前是否把 `status` 设为 `closed` - 收成前是否把 `status` 设为 `closed`

View File

@@ -5,6 +5,7 @@ name: 创作 · 执行步骤
description: >- description: >-
按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。 按已认可的创作流程,执行当前一步工序。提示词与产物 tag 由程序按模块注入。
流程是增量 DAG本步只读禁止自行扩步或重排。 流程是增量 DAG本步只读禁止自行扩步或重排。
outputTags 由程序注入为该步能力 artifact勿把「创作.当前步骤」当产物写回。
version: 1 version: 1
stage: design stage: design
inputTags: inputTags:
@@ -15,8 +16,7 @@ inputTags:
- "用户.修订说明" - "用户.修订说明"
- "设计.创作流程" - "设计.创作流程"
- "创作.当前步骤" - "创作.当前步骤"
outputTags: outputTags: []
- "创作.当前步骤"
inputMerge: latest inputMerge: latest
contextSegments: contextSegments:
- id: flow - id: flow
@@ -46,7 +46,7 @@ contextSegments:
1. 只写本步产物(程序指定的 output tag不要改其它步骤产物 1. 只写本步产物(程序指定的 output tag不要改其它步骤产物
2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名 2. 产物用简洁 JSON 或结构化中文,方便界面渲染;少写机器变量名
3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白 3. 若本步有**默认问题**:程序已先发给用户;首答在「用户.worker答复」/「创作.能力开场白」。**禁止**再用 LLM 重复同一开场白
4. 信息不足 → askUser 12 点(优先 options 4. 信息不足context-fragment 产物把题写在「追问」12 点,建议选项 + 示例),顶层 askUser 为 null。仅完全无法产出时才用顶层 askUser
5. `summary``{本步能力名} · …` 5. `summary``{本步能力名} · …`
6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow 6. **禁止**重排或扩写流程;流程只读。需要追加「再来一次生成规则」等 → 由总管再调 design-flow
7. **本步参数**由编排期写入 steps[].params程序会注入【本步参数】。按参数执行禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参 7. **本步参数**由编排期写入 steps[].params程序会注入【本步参数】。按参数执行禁止再问「这一步生成什么 / 调用哪个规则」。参数缺失或与目录必填项不符 → 停止产出,提示返回 design-flow 补参

View File

@@ -5,6 +5,7 @@ import type {
CompleteWithToolsResult, CompleteWithToolsResult,
LlmProvider, LlmProvider,
StreamCallbacks, StreamCallbacks,
TokenUsage,
} from "./client.js"; } from "./client.js";
import { import {
recordTokenUsage, recordTokenUsage,
@@ -15,6 +16,7 @@ import {
buildContextTrace, buildContextTrace,
type LlmContextTrace, type LlmContextTrace,
} from "../types/context-trace.js"; } from "../types/context-trace.js";
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js";
export type LlmTrackingContext = { export type LlmTrackingContext = {
sessionId?: string; sessionId?: string;
@@ -41,6 +43,77 @@ function capturePendingTrace(
}); });
} }
function recordUsage(
ctx: LlmTrackingContext,
caller: string | undefined,
model: string | undefined,
usage: TokenUsage | undefined,
): void {
if (!usage) return;
const record = recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
orchestratorId: ctx.orchestratorId,
caller: caller ?? "unknown",
model: model ?? "unknown",
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
cachedTokens: usage.cachedTokens,
cacheMissTokens: usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
function summarizeComplete(result: CompleteResult): string {
const bits = [
result.model ? `模型=${result.model}` : "",
`思维链=${result.reasoning?.trim().length ?? 0}`,
`正文=${result.content?.length ?? 0}`,
];
if (result.usage) {
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
}
return bits.filter(Boolean).join(" ");
}
function summarizeTools(result: CompleteWithToolsResult): string {
const tools =
result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无";
const bits = [
result.model ? `模型=${result.model}` : "",
`思维链=${result.reasoning?.trim().length ?? 0}`,
`正文=${result.content?.length ?? 0}`,
`工具=${tools}`,
];
if (result.usage) {
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
}
return bits.filter(Boolean).join(" ");
}
async function loggedLlm<T>(
kind: string,
caller: string | undefined,
run: () => Promise<T>,
summarize: (result: T) => string,
): Promise<T> {
const who = labelCaller(caller);
const mode = labelLlmMode(kind);
const t0 = Date.now();
debugLog("llm", `开始 ${who} · ${mode}`);
try {
const result = await run();
debugLog("llm", `结束 ${who} · ${mode} ${Date.now() - t0}毫秒 ${summarize(result)}`);
return result;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
debugLog("llm", `失败 ${who} · ${mode} ${Date.now() - t0}毫秒 ${detail.slice(0, 160)}`);
throw err;
}
}
export class TokenTrackingProvider implements LlmProvider { export class TokenTrackingProvider implements LlmProvider {
constructor( constructor(
private readonly inner: LlmProvider, private readonly inner: LlmProvider,
@@ -51,29 +124,21 @@ export class TokenTrackingProvider implements LlmProvider {
messages: Parameters<LlmProvider["complete"]>[0], messages: Parameters<LlmProvider["complete"]>[0],
options?: CompleteOptions, options?: CompleteOptions,
): Promise<CompleteResult> { ): Promise<CompleteResult> {
const result = await this.inner.complete(messages, options); return loggedLlm(
const ctx = this.getContext(); "complete",
capturePendingTrace(ctx, messages, options?.caller, result.model); options?.caller,
if (result.usage) { async () => {
const record = recordTokenUsage({ const result = await this.inner.complete(messages, options);
sessionId: ctx.sessionId, const ctx = this.getContext();
bookId: ctx.bookId, capturePendingTrace(ctx, messages, options?.caller, result.model);
bookTitle: ctx.bookTitle, recordUsage(ctx, options?.caller, result.model, result.usage);
orchestratorId: ctx.orchestratorId, if (result.reasoning?.trim()) {
caller: options?.caller ?? "unknown", ctx.pendingReasoning = result.reasoning.trim();
model: result.model ?? "unknown", }
promptTokens: result.usage.promptTokens, return result;
completionTokens: result.usage.completionTokens, },
totalTokens: result.usage.totalTokens, summarizeComplete,
cachedTokens: result.usage.cachedTokens, );
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
} }
async completeStream( async completeStream(
@@ -88,67 +153,51 @@ export class TokenTrackingProvider implements LlmProvider {
if (result.content) callbacks.onContentDelta?.(result.content); if (result.content) callbacks.onContentDelta?.(result.content);
return result; return result;
} }
let reasoningBuf = ""; return loggedLlm(
const result = await inner.completeStream(messages, options, { "stream",
onReasoningDelta: (delta) => { options?.caller,
reasoningBuf += delta; async () => {
let reasoningBuf = "";
const result = await inner.completeStream!(messages, options, {
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf;
callbacks.onReasoningDelta?.(delta);
},
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext(); const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf; capturePendingTrace(ctx, messages, options?.caller, result.model);
callbacks.onReasoningDelta?.(delta); recordUsage(ctx, options?.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
}, },
onContentDelta: callbacks.onContentDelta, summarizeComplete,
}); );
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options?.caller, result.model);
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
orchestratorId: ctx.orchestratorId,
caller: options?.caller ?? "unknown",
model: result.model ?? "unknown",
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cachedTokens: result.usage.cachedTokens,
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
} }
async completeWithTools( async completeWithTools(
messages: Parameters<LlmProvider["completeWithTools"]>[0], messages: Parameters<LlmProvider["completeWithTools"]>[0],
options: CompleteWithToolsOptions, options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> { ): Promise<CompleteWithToolsResult> {
const result = await this.inner.completeWithTools(messages, options); return loggedLlm(
const ctx = this.getContext(); "tools",
capturePendingTrace(ctx, messages, options.caller, result.model); options.caller,
if (result.usage) { async () => {
const record = recordTokenUsage({ const result = await this.inner.completeWithTools(messages, options);
sessionId: ctx.sessionId, const ctx = this.getContext();
bookId: ctx.bookId, capturePendingTrace(ctx, messages, options.caller, result.model);
bookTitle: ctx.bookTitle, recordUsage(ctx, options.caller, result.model, result.usage);
orchestratorId: ctx.orchestratorId, if (result.reasoning?.trim()) {
caller: options.caller ?? "unknown", ctx.pendingReasoning = result.reasoning.trim();
model: result.model ?? "unknown", }
promptTokens: result.usage.promptTokens, return result;
completionTokens: result.usage.completionTokens, },
totalTokens: result.usage.totalTokens, summarizeTools,
cachedTokens: result.usage.cachedTokens, );
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
} }
async completeWithToolsStream( async completeWithToolsStream(
@@ -160,37 +209,29 @@ export class TokenTrackingProvider implements LlmProvider {
if (!inner.completeWithToolsStream) { if (!inner.completeWithToolsStream) {
return this.completeWithTools(messages, options); return this.completeWithTools(messages, options);
} }
let reasoningBuf = ""; return loggedLlm(
const result = await inner.completeWithToolsStream(messages, options, { "tools-stream",
onReasoningDelta: (delta) => { options.caller,
reasoningBuf += delta; async () => {
let reasoningBuf = "";
const result = await inner.completeWithToolsStream!(messages, options, {
onReasoningDelta: (delta) => {
reasoningBuf += delta;
const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf;
callbacks.onReasoningDelta?.(delta);
},
onContentDelta: callbacks.onContentDelta,
});
const ctx = this.getContext(); const ctx = this.getContext();
ctx.pendingReasoning = reasoningBuf; capturePendingTrace(ctx, messages, options.caller, result.model);
callbacks.onReasoningDelta?.(delta); recordUsage(ctx, options.caller, result.model, result.usage);
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
}, },
onContentDelta: callbacks.onContentDelta, summarizeTools,
}); );
const ctx = this.getContext();
capturePendingTrace(ctx, messages, options.caller, result.model);
if (result.usage) {
const record = recordTokenUsage({
sessionId: ctx.sessionId,
bookId: ctx.bookId,
bookTitle: ctx.bookTitle,
orchestratorId: ctx.orchestratorId,
caller: options.caller ?? "unknown",
model: result.model ?? "unknown",
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cachedTokens: result.usage.cachedTokens,
cacheMissTokens: result.usage.cacheMissTokens,
});
ctx.pendingUsage = toMessageTokenUsage(record);
}
if (result.reasoning?.trim()) {
ctx.pendingReasoning = result.reasoning.trim();
}
return result;
} }
} }

293
src/log.ts Normal file
View File

@@ -0,0 +1,293 @@
import { execSync } from "node:child_process";
import {
appendFileSync,
closeSync,
existsSync,
mkdirSync,
openSync,
readSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import type {
ApplyEventResult,
RuntimeEvent,
RuntimePhase,
RuntimeSession,
WaitingReason,
} from "./types/runtime.js";
import { getUserDataDir } from "./config/user-data-dir.js";
const MAX_LOG_BYTES = 2 * 1024 * 1024;
const UTF8_BOM = "\uFEFF";
const KIND_LABEL = {
llm: "模型",
state: "状态",
step: "步骤",
} as const;
const PHASE_LABEL: Record<RuntimePhase, string> = {
idle: "空闲",
running: "执行中",
waiting_user: "等待用户",
done: "完成",
error: "出错",
};
const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
skill_selection: "选能力包",
intake: "收集需求",
input: "等待输入",
approve_step: "确认开干",
review_artifact: "验收产物",
worker_questions: "执行单元提问",
revision: "修订",
next_intent: "下一步意向",
};
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
session_started: "会话开始",
skill_selected: "已选能力包",
user_submitted_input: "用户发言",
user_confirmed_intake: "确认需求",
main_agent_decision_created: "总管决策",
user_approved_next_step: "确认下一步",
user_rejected_next_step: "拒绝下一步",
worker_started: "执行单元开始",
worker_completed: "执行单元完成",
worker_needs_input: "执行单元提问",
user_resolved_sidecar_questions: "回答挂载追问",
user_accepted_artifact: "验收通过",
user_rejected_artifact: "打回产物",
user_requested_revision: "请求修订",
programmatic_review_started: "程序验收开始",
programmatic_review_passed: "程序验收通过",
programmatic_review_failed: "程序验收失败",
flow_completed: "流程结束",
runtime_failed: "运行失败",
};
const EFFECT_LABEL: Record<string, string> = {
invoke_main_agent: "调用总管",
run_worker: "启动执行单元",
resume_worker: "恢复执行单元",
run_programmatic_review: "程序验收",
emit_message: "发消息",
propose_next_creation_step: "提案下一步",
};
const TOOL_LABEL: Record<string, string> = {
read_blackboard: "读黑板",
list_workers: "列执行单元",
list_artifacts: "列产物",
run_worker: "跑执行单元",
ask_user: "问用户",
review_blackboard: "审黑板",
finish: "结束",
};
const ACTION_LABEL: Record<string, string> = {
run_worker: "跑执行单元",
ask_user: "问用户",
review_blackboard: "审黑板",
finish: "结束",
};
let consoleUtf8Ready = false;
/** Windows 控制台切到 UTF-8避免中文日志乱码。 */
export function ensureConsoleUtf8(): void {
if (consoleUtf8Ready) return;
consoleUtf8Ready = true;
if (process.platform !== "win32") return;
try {
// inherit 才会改当前窗口代码页stdio ignore 只影响子进程
execSync("chcp 65001 >NUL", { stdio: "inherit", shell: true, windowsHide: true });
} catch {
/* 无控制台(桌面隐藏启动)时忽略 */
}
try {
process.stdout.setDefaultEncoding("utf8");
process.stderr.setDefaultEncoding("utf8");
} catch {
/* ignore */
}
}
export function getRuntimeLogPath(): string {
return path.join(getUserDataDir(), "logs", "runtime.log");
}
let logFilePrepared = false;
function fileHasUtf8Bom(file: string): boolean {
try {
const fd = openSync(file, "r");
const buf = Buffer.alloc(3);
const n = readSync(fd, buf, 0, 3, 0);
closeSync(fd);
return n >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
} catch {
return false;
}
}
function prepareLogFile(file: string): void {
if (logFilePrepared) return;
mkdirSync(path.dirname(file), { recursive: true });
if (existsSync(file) && statSync(file).size > 0 && !fileHasUtf8Bom(file)) {
try {
renameSync(file, `${file}.old`);
} catch {
/* ignore */
}
}
if (!existsSync(file) || statSync(file).size === 0) {
writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志UTF-8===\n`, "utf8");
}
logFilePrepared = true;
}
function appendRuntimeLog(line: string): void {
if (process.env.VITEST) return;
try {
const file = getRuntimeLogPath();
prepareLogFile(file);
try {
if (statSync(file).size > MAX_LOG_BYTES) {
renameSync(file, `${file}.1`);
writeFileSync(file, `${UTF8_BOM}=== 写作助手运行日志UTF-8===\n`, "utf8");
}
} catch {
/* rotate best-effort */
}
appendFileSync(file, `${line}\n`, "utf8");
} catch {
/* logging must not break the app */
}
}
function stamp(): string {
return new Date().toLocaleTimeString("zh-CN", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function labelPhase(phase: string): string {
return PHASE_LABEL[phase as RuntimePhase] ?? phase;
}
function labelWait(wait: string): string {
if (wait === "-") return "无";
return WAIT_LABEL[wait as WaitingReason["kind"]] ?? wait;
}
export function labelCaller(caller: string | undefined): string {
const who = caller?.trim() || "未知";
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
if (who === "intake_extract") return "需求抽取";
if (who.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`;
if (who === "unknown") return "未知";
return who;
}
export function labelLlmMode(kind: string): string {
switch (kind) {
case "complete":
return "整段";
case "stream":
return "流式";
case "tools":
return "工具";
case "tools-stream":
return "工具流式";
default:
return kind;
}
}
export function labelTool(name: string): string {
return TOOL_LABEL[name] ?? name;
}
export function labelAction(action: string): string {
return ACTION_LABEL[action] ?? action;
}
/** 服务端必要步骤:每次模型请求、状态迁移、创作步骤推进。 */
export function debugLog(kind: "llm" | "state" | "step", line: string): void {
ensureConsoleUtf8();
const text = `[${stamp()} ${KIND_LABEL[kind]}] ${line}`;
console.log(text);
appendRuntimeLog(text);
}
export function sessionSnap(session: RuntimeSession): {
phase: string;
wait: string;
step: string;
worker: string;
} {
return {
phase: session.phase,
wait: session.waitingReason?.kind ?? "-",
step: session.currentStepId ?? "-",
worker: session.currentWorkerId ?? "-",
};
}
function eventHint(event: RuntimeEvent): string {
switch (event.type) {
case "worker_started":
case "worker_needs_input":
return event.payload.workerId;
case "main_agent_decision_created": {
const d = event.payload.decision;
const action = labelAction(d.action);
return d.workerId ? `${action} ${d.workerId}` : action;
}
case "skill_selected":
return event.payload.skill.name;
case "user_accepted_artifact":
case "user_rejected_artifact":
return event.payload.artifactId.slice(0, 8);
default:
return "";
}
}
export function logStateChange(
event: RuntimeEvent,
before: ReturnType<typeof sessionSnap>,
result: ApplyEventResult,
): void {
const after = sessionSnap(result.session);
const bits = [EVENT_LABEL[event.type] ?? event.type];
const hint = eventHint(event);
if (hint) bits.push(hint);
if (before.phase !== after.phase) {
bits.push(`相位 ${labelPhase(before.phase)}${labelPhase(after.phase)}`);
} else {
bits.push(`相位=${labelPhase(after.phase)}`);
}
if (before.wait !== after.wait) {
bits.push(`等待 ${labelWait(before.wait)}${labelWait(after.wait)}`);
} else if (after.wait !== "-") {
bits.push(`等待=${labelWait(after.wait)}`);
}
if (before.step !== after.step) bits.push(`步骤 ${before.step}${after.step}`);
if (before.worker !== after.worker) bits.push(`执行单元 ${before.worker}${after.worker}`);
const effects = result.effects.map((e) => {
const name = EFFECT_LABEL[e.type] ?? e.type;
return e.type === "run_worker" ? `${name} ${e.workerId}` : name;
});
if (effects.length) bits.push(`随后 ${effects.join("")}`);
if (result.error) bits.push(`出错 ${result.error.slice(0, 120)}`);
debugLog("state", bits.join(" "));
}

View File

@@ -23,48 +23,15 @@ function buildMainAgentSystemPrompt(
? workers.map((w) => `- ${w.id}${w.description}`).join("\n") ? workers.map((w) => `- ${w.id}${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)"; : "- (当前 skill 未加载 worker 列表)";
return `你是写作系统的编排器Main Agent。你的职责是调度执行单元worker而不是直接创作正文 return `你是创作节点路由器。只读取「设计.创作流程」DAG并选择下一个要执行的创作节点
规则: - DAG 不存在或需要追加节点:调用 design-flow。
1. 你不能直接生成小说/文章正文 - DAG 中已有下一个可执行节点:调用 design-step
2. 你不能修改运行状态statePatchAllowed 必须始终为 false - 只调度 worker不创作正文不规划 DAG 之外的流程
3. 你只能建议下一步动作ask_user、run_worker、create_temp_worker、review_blackboard、finish。
4. 当信息不足时,使用 ask_userassessment 是主内容完备度评价questions 挂在其下且用户可跳过;一次 12 题。
5. 当需要执行任务时,使用 run_worker只指定 workerId。不要指定 inputTags 或 outputTags——Runtime 从 Worker Skill 读取。
6. requiresApproval 表示运行 worker 前是否需要用户确认。代笔模式通常为 true。
7. run_worker 可选 workerContext{ "roleId": "A" } 或 { "chance": { "op":"roll", "expression":"2d6" } }(机遇裁定走程序,勿让模型编随机)。
8. 你不能把未验收内容当作事实。
9. 向用户提问是 worker 的技能ask_user tool不是独立 worker。编排器只在调度层提问。
10. blackboardIndex 只有 tag 索引,不含正文 content。
## 调度思维
用需求正推:「用户需要 [具体体验/技能] → 调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
禁止否定式路由:「某模式 / 背景形态 → 不需要某步骤 / 跳过某 worker」。
反例(禁止):「背景为单一角色,无需世界构建」「不是规则怪谈,跳过 write-rules」「默认上世界模拟套件」
正例:「用户已选世界模拟器配方且要网恋对话 → design-flow 排出近期增量步骤(可调味、可后补;有编排参数的步骤须钉 params→ 用户认可后反复 design-step需要再补规则/实例 → 再 design-flow 追加同技能并写齐 params → 收成后若需开局 → opening-generator用户手动进 play」
当前 skill 可用 workerworkerId 必须与下列 id 完全一致): 当前 skill 可用 workerworkerId 必须与下列 id 完全一致):
${workerLines} ${workerLines}
## design 优先顺序
- 尚无「设计.创作流程」→ **design-flow**(近期 horizonstatus=open未选配方则先请用户选
- 流程已验收且还有未完成步骤 → **design-step**(一次一步;技能由程序注入)
- 已列步骤都验收但流程仍 **status=open** → 再 **design-flow**(追加反复步或 closed
- 禁止调度已废弃的 design-core / design-fixed / design-worker / design-refine
- 终稿已 accept 且可用 opening-generator、尚无开场产物 → opening-generator
- 进 play 由用户手动决定
- **禁止**替用户猜测或改选配方
- **禁止**一次编排排死全程固定 DAG
## play 优先顺序lifecycleStage=play
- 严格按声明管线顺序调度play_slots 展开序):**auditor旁观维护→ perspective? → gm主世界层→ narrator转述**
- auditor / gm / perspective 的 acceptance 多为 continue跑完立刻调度下一槽不要中途 ask_user
- 仅 narrator或规格标明 review 的终稿槽)停下来等人看
- chance 仅在需要真随机时按需 run_worker不插入每轮固定序
- slots.pendingSideEffectWorkers 非空时优先调度其中 workerId
- **禁止**发明未声明的 play ref**禁止**把旁观维护当成正文作者
输出必须是 JSON 对象,字段: 输出必须是 JSON 对象,字段:
{ {
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish", "action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
@@ -110,40 +77,21 @@ export class MainAgent {
export function buildMainAgentUserPrompt(context: MainAgentContext): string { export function buildMainAgentUserPrompt(context: MainAgentContext): string {
const { session, blackboardIndex, availableWorkers } = context; const { session, blackboardIndex, availableWorkers } = context;
const stage = String(session.slots?.uiLifecycleStage ?? "design"); const creationTags = new Set([
const pendingSide = session.slots?.pendingSideEffectWorkers; "设计.创作流程",
"创作.当前步骤",
"创作.当前单位",
"创作.已验收单位",
"用户.下一步意向",
]);
return JSON.stringify( return JSON.stringify(
{ {
runtimePhase: session.phase,
waitingReason: session.waitingReason,
lifecycleStage: stage,
flowId: session.flowId,
currentStepId: session.currentStepId, currentStepId: session.currentStepId,
currentWorkerId: session.currentWorkerId, creationDagTags: blackboardIndex.filter((entry) =>
acceptanceMode: session.acceptanceMode, creationTags.has(entry.tag),
slots: session.slots, ),
pendingSideEffectWorkers: pendingSide ?? null,
pendingDecision: session.pendingDecision
? {
id: session.pendingDecision.id,
action: session.pendingDecision.action,
reason: session.pendingDecision.reason,
}
: null,
pendingArtifactId: session.pendingArtifactId,
artifacts: session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
blackboardIndex,
availableWorkers, availableWorkers,
instruction: instruction: "读取创作 DAG选择下一个创作节点。",
stage === "play"
? "play按声明序 auditor→perspective?→gm→narratorcontinue 槽连跑;仅终稿 review 停pendingSideEffectWorkers 优先;禁止未声明 ref。"
: "根据当前状态决定下一步。尚无设计.创作流程 → design-flow有未完成步骤 → design-stepsteps 做完但 status=open → 再 design-flow禁止旧 design-core/fixed/worker/refine。已 accept 且需开局 → opening-generator。进 play 由用户手动。",
}, },
null, null,
2, 2,

View File

@@ -6,6 +6,7 @@ import { isMainAgentTerminalTool } from "../types/tools.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js"; import { MAIN_AGENT_TOOL_DEFINITIONS } from "./tools.js";
import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js"; import { buildMainAgentUserPrompt, parseMainAgentDecision } from "./main-agent.js";
import type { MainAgentContext } from "./main-agent.js"; import type { MainAgentContext } from "./main-agent.js";
import { debugLog } from "../log.js";
export type ToolLoopHandlers = { export type ToolLoopHandlers = {
readBlackboard: (tags: string[]) => Record<string, string>; readBlackboard: (tags: string[]) => Record<string, string>;
@@ -20,7 +21,7 @@ export type ToolLoopHandlers = {
onToolCall?: (name: string, detail: string) => void; onToolCall?: (name: string, detail: string) => void;
/** 思维链 / reasoning 流式增量(供 UI 实时展示) */ /** 思维链 / reasoning 流式增量(供 UI 实时展示) */
onThinkingDelta?: (delta: string) => void; onThinkingDelta?: (delta: string) => void;
/** 一轮 LLM 调用结束后的完整思考文本 */ /** 一轮 LLM 调用结束后的完整思维链(不含正文) */
onThinkingDone?: (text: string) => void; onThinkingDone?: (text: string) => void;
}; };
@@ -40,71 +41,17 @@ function buildToolLoopSystemPrompt(
? workers.map((w) => `- ${w.id}${w.description}`).join("\n") ? workers.map((w) => `- ${w.id}${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)"; : "- (当前 skill 未加载 worker 列表)";
return `你是写作系统的总管Main Agent。你在 running 相位通过 **tool call** 推进流程 return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点
## 能力边界 1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
1. 你不能直接生成小说/文章正文 2. 没有 DAG 或 DAG 需要追加节点时run_worker(design-flow)
2. 你不能修改运行状态 3. DAG 已给出下一个可执行节点时run_worker(design-step)
3. 先用 read_blackboard / list_workers / list_artifacts 收集信息,再决定下一步 4. 不创作正文,不改写 DAG不处理运行状态或游玩流程
4. 终止动作只能用 toolask_user、run_worker、review_blackboard、finish。
- ask_user**主内容是 assessment内容完备度评价**questions 挂在其下且可被用户跳过。
· assessment必填给用户看的评价正文。以【核心体验】为首按需列维度内容/人物关系/感官/背景规则/意义主题…),不必凑齐。每维写完备度%、已知、待探;待探用【】标互斥或可组合方向。
· questions必填数组语义可选只针对 assessment「待探」里想确认的点一次 12 题。用户可不答、直接让你基于现有信息继续。prompt=明确题干options=24 条**建议示范**required 默认 false。
· reason调度短句正推勿塞长文。
· 能推断就别问;已写在 用户.需求 里的不要重复问。
5. run_worker 只传 workerIdinputTags/outputTags 由 Runtime 从 Worker Skill 读取。
6. requiresApproval=true 时 run_worker 需用户确认后再执行。
7. 不能把未验收产物当作已定事实。
## 调度思维(必须遵守)
用 **需求正推**,禁止 **模式否定式** 表述。
**要这样思考(正推):**
「用户需要 [具体体验/能力/产物] → 因此调用 [worker/skill] 来 [生成/补充/调整] [什么],以便更好满足用户。」
**不要这样思考(反例):**
「这是 xxx 模式 / 形态 → 不需要 xxx 步骤 / 跳过 xxx。」
「背景为单一角色,无需世界构建。」
「不是规则怪谈,跳过 write-rules。」
示例(好):
- 「用户已选配方且要 1v1 网恋 → design-flow 排出近期增量步骤(可调味)→ 认可后反复 design-step不够再追加。」
- 「尚无设计.创作流程 → run_worker(design-flow);有未完成步骤 → design-stepsteps 完但 status=open → 再 design-flow。」
- 「运行规格已 accept 且声明需要开局 → 调用 opening-generator 写开场白。」
示例(坏):
- 「调度 design-core / design-fixed已废弃。」
- 「一次 design-flow 排死全程固定 DAG。」
- 「跳过流程编排直接写满运行规格。」
- 「默认先上世界模拟全套再问用户要什么。」
- 「替用户改选 / 猜测配方。」
在 reasoning / 可见 content 中请用 **正推句式** 写出调度理由;终止 tool 的 reason 字段同样用正推表述。
## design 阶段提示
- 尚无「设计.创作流程」→ **优先 design-flow**(近期 horizonstatus=open未选则先请用户选
- 流程已验收、还有未完成步骤 → **design-step**(一次一步)。
- 已列步骤全验收但 **status=open** → 再 **design-flow**(追加生成规则/具体实例等并钉齐 params或设 closed
- 有编排参数的步骤缺必填 params → 先让 design-flow askUser不要空壳跑 design-step。
- 禁止 run_worker(design-core|design-fixed|design-worker|design-refine)——已废弃。
- 不要重复问用户「想做什么」——意图已在 用户.需求。
- 运行规格已 accept且可用 worker 含 opening-generator尚无开场产物 → opening-generator。
- 进 play 由用户手动决定。
- 未声明开局、用户也未要求开场时,不要硬调 opening-generator。
- **禁止**替用户猜测或改选配方。
- **禁止**一次编排排死全程固定长链。
## play 阶段提示lifecycleStage=play 时优先)
- 管线顺序:**auditor旁观维护→ perspective? → gm主世界层→ narrator**。
- auditor/gm/perspective 多为 continue写完立刻 run 下一槽,不要中途 ask_user。
- 只在 narrator或 review 终稿槽)停给人看。
- chance 按需pendingSideEffectWorkers 非空则优先调度。
- 禁止未声明 play ref旁观维护默认空操作不是正文作者。
当前 skill 可用 worker 当前 skill 可用 worker
${workerLines} ${workerLines}
信息不足时可多次 read_blackboard一旦调用终止 tool本轮循环结束`; 选定节点后立即调用 run_worker`;
} }
async function completeToolsPreferStream( async function completeToolsPreferStream(
@@ -172,14 +119,9 @@ function assistantMessageFromToolCalls(
}; };
} }
function emitThinkingDone(handlers: ToolLoopHandlers, parts: { function emitThinkingDone(handlers: ToolLoopHandlers, reasoning?: string): void {
reasoning?: string; const text = reasoning?.trim() ?? "";
content?: string | null; if (text) handlers.onThinkingDone?.(text);
}): void {
const reasoning = parts.reasoning?.trim() ?? "";
const content = parts.content?.trim() ?? "";
const combined = [reasoning, content].filter(Boolean).join("\n\n").trim();
if (combined) handlers.onThinkingDone?.(combined);
} }
/** /**
@@ -202,12 +144,12 @@ export async function runMainAgentToolLoop(
const toolTrace: string[] = []; const toolTrace: string[] = [];
const streamCallbacks: StreamCallbacks = { const streamCallbacks: StreamCallbacks = {
onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta), onReasoningDelta: (delta) => handlers.onThinkingDelta?.(delta),
onContentDelta: (delta) => handlers.onThinkingDelta?.(delta),
}; };
for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) { for (let iteration = 1; iteration <= MAX_TOOL_LOOP_ITERATIONS; iteration++) {
debugLog("llm", `总管循环 第${iteration}`);
const result = await completeToolsPreferStream(llm, messages, streamCallbacks); const result = await completeToolsPreferStream(llm, messages, streamCallbacks);
emitThinkingDone(handlers, result); emitThinkingDone(handlers, result.reasoning);
if (result.toolCalls.length === 0) { if (result.toolCalls.length === 0) {
if (result.content?.trim()) { if (result.content?.trim()) {

View File

@@ -22,118 +22,11 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
}, },
}, },
}, },
{
type: "function",
function: {
name: "list_workers",
description: "列出当前 skill 可调度的 worker id 与说明。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "list_artifacts",
description: "列出当前会话 worker 产物id、workerId、status、summary。",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "ask_user",
description:
"向用户追问。assessment 是给用户看的主内容完备度评价questions 挂在其下,用户可跳过并请你基于现有信息继续。将暂停等待用户。",
parameters: {
type: "object",
properties: {
reason: {
type: "string",
description:
"调度层短理由(正推:用户需要澄清 X → 因此追问)。勿把长文评价写这里。",
},
assessment: {
type: "string",
description:
"给用户看的内容完备度评价Markdown。以【核心体验】为首要按需列维度不必凑齐每维完备度%、已知、待探。待探用【】标出互斥/可组合方向。评价只服务「用户想要什么」。",
},
message: {
type: "string",
description:
"兼容旧字段:等同 assessment。优先传 assessment。",
},
questions: {
type: "array",
description:
"挂在 assessment 下的可选追问(用户可不答)。基于「待探」出题,一次 12 题。prompt=明确选择题干options=建议示范(可改写后采用)。",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "稳定 id如 q1 / stance",
},
prompt: {
type: "string",
description:
"题干:引导用户选定一种详细体验倾向(例:「你更倾向于哪种皇帝的享受?」)。",
},
options: {
type: "array",
description:
"24 个建议示范。label 写完整可采纳文案(可先一句场景钩子再点题),不是标签词。",
items: {
type: "object",
properties: {
id: {
type: "string",
description: "A/B/C…",
},
label: {
type: "string",
description:
"建议正文:用户点选后可直接当答案,也可改写。例:「冰冷的、主宰一切的权力感——九重宫阙一言定生死…」",
},
editable: {
type: "boolean",
description: "默认 true点文案可改写点字母才选中",
},
},
required: ["label"],
},
},
allowOther: {
type: "boolean",
description: "默认 true允许 Other 自拟",
},
required: {
type: "boolean",
description: "默认 false可选追问仅关键阻塞题才 true",
},
},
required: ["prompt", "options"],
},
},
},
required: ["reason", "assessment", "questions"],
additionalProperties: false,
},
},
},
{ {
type: "function", type: "function",
function: { function: {
name: "run_worker", name: "run_worker",
description: description: "执行选中的下一个创作节点。",
"调度 worker。常规只传 workerId。调度 chance机遇裁定时传 chance 请求对象(程序掷骰/比点/抽签,禁止让模型编随机)。",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -143,50 +36,10 @@ export const MAIN_AGENT_TOOL_DEFINITIONS: ToolDefinition[] = [
type: "boolean", type: "boolean",
description: "true 时需用户确认后才执行", description: "true 时需用户确认后才执行",
}, },
roleId: {
type: "string",
description: "role-decide 等 worker 的当前角色 id",
},
chance: {
type: "object",
description:
"仅 workerId=chance{ op:'roll'|'compare'|'draw'|'pick', ... }。例 roll:{expression:'2d6+1'}draw:{pool:['a','b'],count:1}",
},
}, },
required: ["workerId", "reason", "requiresApproval"], required: ["workerId", "reason", "requiresApproval"],
additionalProperties: false, additionalProperties: false,
}, },
}, },
}, },
{
type: "function",
function: {
name: "review_blackboard",
description: "向用户说明当前黑板与进度概况,并暂停等待用户回复。",
parameters: {
type: "object",
properties: {
reason: { type: "string" },
summary: { type: "string", description: "给用户看的概况说明" },
},
required: ["reason", "summary"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "finish",
description: "正常结束当前创作流程。",
parameters: {
type: "object",
properties: {
reason: { type: "string" },
},
required: ["reason"],
additionalProperties: false,
},
},
},
]; ];

View File

@@ -26,7 +26,10 @@ import {
readIntakeValues, readIntakeValues,
synthesizeDemandText, synthesizeDemandText,
} from "../intake/intake.js"; } from "../intake/intake.js";
import { normalizeQuestions } from "../skills/question-protocol.js"; import {
isModuleOpeningQuestions,
normalizeQuestions,
} from "../skills/question-protocol.js";
import type { QuestionItem } from "../types/questions.js"; import type { QuestionItem } from "../types/questions.js";
function nowIso(): string { function nowIso(): string {
@@ -119,6 +122,67 @@ function updateArtifact(
}; };
} }
/** 最近一次被拒 / 待修订的产物(用于 revision 等待态补交意见后重跑) */
function findRevisionTargetArtifact(
session: RuntimeSession,
): ArtifactRecord | undefined {
if (session.pendingArtifactId) {
const pending = findArtifact(session, session.pendingArtifactId);
if (pending) return pending;
}
for (let i = session.artifacts.length - 1; i >= 0; i -= 1) {
const a = session.artifacts[i];
if (a.status === "revision_requested" || a.status === "rejected") {
return a;
}
}
return undefined;
}
/**
* 已有修改意见 → 写入「用户.修订说明」并立刻重跑同一 worker。
* 语义:在已有产物上按意见改(上下文会注入待改底稿),不是推倒重做。
* 对应验收底栏:看产物 → 输入意见 →「按意见修改」。
*/
function beginRevisionRerun(
session: RuntimeSession,
event: RuntimeEvent,
artifact: ArtifactRecord,
instruction: string,
): ApplyEventResult {
const trimmed = instruction.trim();
const slots: Record<string, unknown> = {
...session.slots,
"用户.修订说明": trimmed,
"用户.最新输入": trimmed,
revisionInstruction: trimmed,
// 本次重跑的底稿;重跑写不出新版时只许退回这一份
revisionTargetArtifactId: artifact.id,
};
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "revision_requested" }),
event,
);
return {
session: touch(
running({
...next,
slots,
pendingArtifactId: undefined,
pendingDecision: undefined,
currentWorkerId: artifact.workerId,
currentStepId: artifact.stepId ?? session.currentStepId,
}),
),
effects: [
{
type: "run_worker",
workerId: artifact.workerId,
},
],
};
}
/** 创建 idle 状态的新会话 */ /** 创建 idle 状态的新会话 */
export function createSession( export function createSession(
presetId: string, presetId: string,
@@ -154,6 +218,7 @@ export function getAllowedEvents(
"worker_started", "worker_started",
"worker_completed", "worker_completed",
"worker_needs_input", "worker_needs_input",
"worker_revision_produced_nothing",
"programmatic_review_started", "programmatic_review_started",
"programmatic_review_passed", "programmatic_review_passed",
"programmatic_review_failed", "programmatic_review_failed",
@@ -204,7 +269,8 @@ export function canApplyEvent(
reason?.kind === "intake" || reason?.kind === "intake" ||
reason?.kind === "input" || reason?.kind === "input" ||
reason?.kind === "worker_questions" || reason?.kind === "worker_questions" ||
reason?.kind === "revision" reason?.kind === "revision" ||
reason?.kind === "next_intent"
); );
case "user_confirmed_intake": case "user_confirmed_intake":
return reason?.kind === "intake"; return reason?.kind === "intake";
@@ -256,6 +322,14 @@ function handleWorkerCompleted(
const mode = session.acceptanceMode ?? "user_confirmed"; const mode = session.acceptanceMode ?? "user_confirmed";
let next = appendHistory(session, event); let next = appendHistory(session, event);
if (next.slots.revisionTargetArtifactId) {
// 已写出新版,旧底稿不再是退回目标
next = {
...next,
slots: { ...next.slots, revisionTargetArtifactId: undefined },
};
}
if (mode === "user_confirmed") { if (mode === "user_confirmed") {
next = updateArtifact(next, artifact.id, { status: "under_review" }); next = updateArtifact(next, artifact.id, { status: "under_review" });
return { return {
@@ -454,6 +528,62 @@ export function applyEvent(
}; };
} }
if (session.waitingReason?.kind === "next_intent") {
if (text) {
slots["用户.下一步意向"] = text;
slots["用户.最新输入"] = text;
}
const next = appendHistory(
{
...session,
slots,
resumeContext: undefined,
},
event,
);
return {
session: touch(running(next)),
effects: [{ type: "propose_next_creation_step" }],
};
}
if (session.waitingReason?.kind === "revision") {
const instruction =
text.trim() || session.waitingReason.instruction?.trim() || "";
if (!instruction) {
return {
session: waiting(touch(appendHistory({ ...session, slots }, event)), {
kind: "revision",
instruction: session.waitingReason.instruction,
}),
effects: [
{
type: "emit_message",
message: "请说明要改哪里,再发送。",
},
],
};
}
const target = findRevisionTargetArtifact(session);
if (target) {
return beginRevisionRerun(session, event, target, instruction);
}
slots["用户.修订说明"] = instruction;
slots["用户.最新输入"] = instruction;
slots.revisionInstruction = instruction;
return {
session: touch(
running(
appendHistory(
{ ...session, slots, pendingArtifactId: undefined },
event,
),
),
),
effects: [{ type: "invoke_main_agent" }],
};
}
if (session.waitingReason?.kind === "worker_questions") { if (session.waitingReason?.kind === "worker_questions") {
slots["用户.worker答复"] = text; slots["用户.worker答复"] = text;
if (text) slots["用户.最新输入"] = text; if (text) slots["用户.最新输入"] = text;
@@ -660,6 +790,7 @@ export function applyEvent(
acceptanceMode: session.acceptanceMode ?? "user_confirmed", acceptanceMode: session.acceptanceMode ?? "user_confirmed",
questions: normalized, questions: normalized,
}; };
const openingAsk = isModuleOpeningQuestions(normalized);
return { return {
session: waiting( session: waiting(
appendHistory( appendHistory(
@@ -675,7 +806,10 @@ export function applyEvent(
effects: [ effects: [
{ {
type: "emit_message", type: "emit_message",
message: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`, // 默认问题正文由说话面 openingGuide 展示,勿再整段塞进调度消息
message: openingAsk
? `[Worker] ${event.payload.workerId} · 默认问题(请在主栏按引导作答)`
: `[Worker] ${event.payload.workerId} 提问:\n${normalized.map((q) => `- ${q.prompt}`).join("\n")}`,
}, },
], ],
}; };
@@ -690,16 +824,10 @@ export function applyEvent(
typeof event.payload.assessment === "string" typeof event.payload.assessment === "string"
? event.payload.assessment.trim() ? event.payload.assessment.trim()
: ""; : "";
const effects = [...result.effects]; // 追问与产物同一次产出:只挂 review_artifact勿再 emit 独立「可选追问」消息
if (sidecar?.length) { //(否则历史里会拆成 worker_output + worker_questions 两段)
effects.push({
type: "emit_message",
message: `[Worker] 可选追问(可跳过,直接接受目前产物):\n${sidecar.map((q) => `- ${q.prompt}`).join("\n")}`,
});
}
return { return {
...result, ...result,
effects,
session: waiting(result.session, { session: waiting(result.session, {
kind: "review_artifact", kind: "review_artifact",
artifactId: result.session.pendingArtifactId, artifactId: result.session.pendingArtifactId,
@@ -711,6 +839,42 @@ export function applyEvent(
return result; return result;
} }
case "worker_revision_produced_nothing": {
const artifact = findArtifact(session, event.payload.artifactId);
if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`);
}
const retryQuestions = normalizeQuestions(event.payload.questions).map(
(q) => ({ ...q, required: false }),
);
const next = appendHistory(
updateArtifact(session, artifact.id, { status: "under_review" }),
event,
);
return {
session: waiting(
{
...next,
slots: { ...next.slots, revisionTargetArtifactId: undefined },
pendingArtifactId: artifact.id,
currentWorkerId: undefined,
},
{
kind: "review_artifact",
artifactId: artifact.id,
questions: retryQuestions.length ? retryQuestions : undefined,
},
),
effects: [
{
type: "emit_message",
message:
"[系统] 这次按意见重写没能产出可验收的新版本,已退回上一版产物:可补充说明再改,或直接接受上一版。",
},
],
};
}
case "user_resolved_sidecar_questions": { case "user_resolved_sidecar_questions": {
const reason = session.waitingReason; const reason = session.waitingReason;
if (reason?.kind !== "review_artifact") { if (reason?.kind !== "review_artifact") {
@@ -745,22 +909,43 @@ export function applyEvent(
} }
const slots: Record<string, unknown> = { ...session.slots }; const slots: Record<string, unknown> = { ...session.slots };
// 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play // 仅终稿 tag「设计.worker集」表示完整 Worker 集验收;草稿单位验收不得开 play
if (artifact.outputTags.some((tag) => tag === "设计.worker集")) { const isFinalSet = artifact.outputTags.some((tag) => tag === "设计.worker集");
if (isFinalSet) {
slots.designInstanceReady = true; slots.designInstanceReady = true;
} }
const next = appendHistory( const next = appendHistory(
updateArtifact(session, artifact.id, { status: "accepted" }), updateArtifact(session, artifact.id, { status: "accepted" }),
event, event,
); );
return { const cleared = {
session: touch( ...next,
running({ slots,
...next, pendingArtifactId: undefined,
slots, pendingDecision: undefined,
pendingArtifactId: undefined, currentWorkerId: undefined,
pendingDecision: undefined, };
// 创作步验收后:先问下一步意向,再展示下一节点确认开干(勿直接连跑)
const gateNextIntent =
!isFinalSet &&
(artifact.workerId === "design-step" ||
artifact.workerId === "design-flow");
if (gateNextIntent) {
return {
session: waiting(cleared, {
kind: "next_intent",
afterWorkerId: artifact.workerId,
}), }),
), effects: [
{
type: "emit_message",
message:
"这一步已收下。下一步想写什么?可留空,直接发送后看下一节点并确认开干。",
},
],
};
}
return {
session: touch(running(cleared)),
effects: [{ type: "invoke_main_agent" }], effects: [{ type: "invoke_main_agent" }],
}; };
} }
@@ -770,19 +955,21 @@ export function applyEvent(
if (!artifact) { if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`); return fail(session, `Artifact not found: ${event.payload.artifactId}`);
} }
const reason = event.payload.reason?.trim() ?? "";
// 验收态已写意见 → 在现有产物上按意见改;无意见才进入 revision 等待补交
if (reason) {
return beginRevisionRerun(session, event, artifact, reason);
}
const next = appendHistory( const next = appendHistory(
updateArtifact(session, artifact.id, { status: "rejected" }), updateArtifact(session, artifact.id, { status: "rejected" }),
event, event,
); );
return { return {
session: waiting(next, { session: waiting(next, { kind: "revision" }),
kind: "revision",
instruction: event.payload.reason,
}),
effects: [ effects: [
{ {
type: "emit_message", type: "emit_message",
message: event.payload.reason ?? "产物已被拒绝,等待修改指示。", message: "已保留当前产物,请说明要改哪里。",
}, },
], ],
}; };
@@ -800,15 +987,26 @@ export function applyEvent(
effects: [{ type: "invoke_main_agent" }], effects: [{ type: "invoke_main_agent" }],
}; };
} }
const instruction = event.payload.instruction?.trim() ?? "";
const artifactId =
event.payload.artifactId ?? session.pendingArtifactId;
const artifact = artifactId
? findArtifact(session, artifactId)
: findRevisionTargetArtifact(session);
if (instruction && artifact) {
return beginRevisionRerun(session, event, artifact, instruction);
}
return { return {
session: waiting( session: waiting(
appendHistory({ ...session, pendingArtifactId: undefined }, event), appendHistory(session, event),
{ kind: "revision", instruction: event.payload.instruction }, { kind: "revision", instruction: instruction || undefined },
), ),
effects: [ effects: [
{ {
type: "emit_message", type: "emit_message",
message: `返工请求:${event.payload.instruction}`, message: instruction
? `修改意见:${instruction}`
: "请说明要改哪里(在现有产物上改)。",
}, },
], ],
}; };
@@ -840,20 +1038,16 @@ export function applyEvent(
if (!artifact) { if (!artifact) {
return fail(session, `Artifact not found: ${event.payload.artifactId}`); return fail(session, `Artifact not found: ${event.payload.artifactId}`);
} }
const next = appendHistory( const reason = event.payload.reason?.trim() || "程序验收失败";
updateArtifact(session, artifact.id, { status: "revision_requested" }), const result = beginRevisionRerun(session, event, artifact, reason);
event,
);
return { return {
session: waiting(next, { ...result,
kind: "revision",
instruction: event.payload.reason,
}),
effects: [ effects: [
{ {
type: "emit_message", type: "emit_message",
message: `程序验收失败:${event.payload.reason}`, message: `程序验收未过,按意见在现有产物上改:${reason}`,
}, },
...result.effects,
], ],
}; };
} }

View File

@@ -57,18 +57,36 @@ import {
import { import {
CREATION_FLOW_TAG, CREATION_FLOW_TAG,
CREATION_CURRENT_STEP_TAG, CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_MODULE_OPENING_TAG, CREATION_MODULE_OPENING_TAG,
CREATION_MODULE_OPENING_STATE_TAG, CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE, SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog,
mergeCreationFlowPreservingAccepted,
nextPendingStep, nextPendingStep,
parseCreationFlow, parseCreationFlow,
parseModuleOpeningState, parseModuleOpeningState,
patchCreationFlowStepParams,
pickRecordedStepId,
resolveDesignStepBinding, resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag,
stepUnitId, stepUnitId,
stringifyCreationFlow,
stringifyModuleOpeningState, stringifyModuleOpeningState,
type CreationFlowStepParams,
type ModuleParamSpec,
} from "../skills/creation-flow.js"; } from "../skills/creation-flow.js";
import { normalizeQuestions } from "../skills/question-protocol.js"; import {
appendAskedQuestions,
dropAlreadyAskedQuestions,
MODULE_OPENING_QUESTION_ID,
normalizeQuestions,
parseAskedQuestions,
SLOT_ASKED_QUESTIONS,
} from "../skills/question-protocol.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { debugLog, labelAction, logStateChange, sessionSnap } from "../log.js";
import { import {
executeChance, executeChance,
resolveChanceRequest, resolveChanceRequest,
@@ -363,10 +381,18 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/** 统一事件入口:applyEvent + 更新 session + 处理 effects + 可选总管 LLM */ /** applyEvent 并打状态日志dispatch / 总管决策共用) */
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> { private commitEvent(event: RuntimeEvent): ApplyEventResult {
const before = sessionSnap(this.session);
const result = applyEvent(this.session, event); const result = applyEvent(this.session, event);
this.session = result.session; this.session = result.session;
logStateChange(event, before, result);
return result;
}
/** 统一事件入口applyEvent + 更新 session + 处理 effects + 可选总管 LLM */
async dispatch(event: RuntimeEvent): Promise<ApplyEventResult> {
const result = this.commitEvent(event);
if ( if (
event.type === "user_submitted_input" || event.type === "user_submitted_input" ||
@@ -416,14 +442,26 @@ export class PhaseRuntime {
source: "user", source: "user",
}); });
} }
const revision = session.slots.revisionInstruction; const revision =
(typeof session.slots["用户.修订说明"] === "string" &&
session.slots["用户.修订说明"]) ||
(typeof session.slots.revisionInstruction === "string" &&
session.slots.revisionInstruction);
if (typeof revision === "string" && revision.trim()) { if (typeof revision === "string" && revision.trim()) {
this.blackboard.write({ this.blackboard.write({
tag: "用户.修说明", tag: "用户.修说明",
content: revision.trim(), content: revision.trim(),
source: "user", source: "user",
}); });
} }
const nextIntent = session.slots["用户.下一步意向"];
if (typeof nextIntent === "string" && nextIntent.trim()) {
this.blackboard.write({
tag: "用户.下一步意向",
content: nextIntent.trim(),
source: "user",
});
}
} }
/** /**
@@ -490,10 +528,15 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/** 用户确认 pendingDecisionapprove_step */ /** 用户确认 pendingDecisionapprove_step;可顺带钉死下一步 params */
async approve(): Promise<RuntimeSession> { async approve(stepParams?: CreationFlowStepParams): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id; const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待确认的决策"); if (!id) throw new Error("当前没有待确认的决策");
if (stepParams && Object.keys(stepParams).length > 0) {
this.applyProposedStepParams(stepParams);
}
const readyErr = this.assertProposedStepReady();
if (readyErr) throw new Error(readyErr);
await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } }); await this.dispatch({ type: "user_approved_next_step", payload: { decisionId: id } });
return this.session; return this.session;
} }
@@ -577,7 +620,7 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/** 用户拒绝 pendingArtifact,进入 revision */ /** 用户拒绝 pendingArtifact:有意见则立刻按「用户.修订说明」在现有产物上改 */
async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> { async rejectArtifact(reason?: string, artifactId?: string): Promise<RuntimeSession> {
const id = artifactId ?? this.session.pendingArtifactId; const id = artifactId ?? this.session.pendingArtifactId;
if (!id) throw new Error("当前没有待拒绝的产物"); if (!id) throw new Error("当前没有待拒绝的产物");
@@ -588,12 +631,39 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/**
* 按意见重跑却没写出新产物:退回**本次修订的那一份**产物验收,重试追问改挂为可选。
* 只认 beginRevisionRerun 钉下的底稿;否则会把无关的旧产物(如上一轮流程编排)
* 错误地挂回验收面。首跑无产物仍走阻塞追问。
* @returns true = 已退回验收,调用方应 return
*/
private async restoreRevisionTargetForReview(
workerId: string,
questions: import("../types/questions.js").QuestionItem[] | string[],
): Promise<boolean> {
const targetId = this.session.slots.revisionTargetArtifactId;
if (typeof targetId !== "string" || !targetId) return false;
const target = this.session.artifacts.find(
(a) =>
a.id === targetId &&
a.workerId === workerId &&
a.status === "revision_requested",
);
if (!target) return false;
await this.dispatch({
type: "worker_revision_produced_nothing",
payload: { artifactId: target.id, questions },
});
return true;
}
/** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */ /** worker 调用 ask_user 能力时,由外层触发此事件(仅无产物时阻塞) */
async workerAsk( async workerAsk(
questions: import("../types/questions.js").QuestionItem[] | string[], questions: import("../types/questions.js").QuestionItem[] | string[],
): Promise<RuntimeSession> { ): Promise<RuntimeSession> {
const workerId = this.session.currentWorkerId; const workerId = this.session.currentWorkerId;
if (!workerId) throw new Error("当前没有运行中的 worker"); if (!workerId) throw new Error("当前没有运行中的 worker");
this.rememberAskedQuestions(normalizeQuestions(questions));
await this.dispatch({ await this.dispatch({
type: "worker_needs_input", type: "worker_needs_input",
payload: { workerId, stepId: this.session.currentStepId, questions }, payload: { workerId, stepId: this.session.currentStepId, questions },
@@ -601,6 +671,35 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
private askedQuestionHistory() {
return parseAskedQuestions(this.session.slots[SLOT_ASKED_QUESTIONS]);
}
private rememberAskedQuestions(
questions: readonly import("../types/questions.js").QuestionItem[],
): void {
if (!questions.length) return;
const next = appendAskedQuestions(this.askedQuestionHistory(), questions);
this.session = {
...this.session,
slots: { ...this.session.slots, [SLOT_ASKED_QUESTIONS]: next },
};
}
/**
* 挂在验收态的可跳过追问:本局问过的不再问一次。
* 编排层常把上一步已问过的体验题再抛一遍,用户会读成「又要重做那一步」。
*/
private freshSidecarQuestions(
questions: readonly import("../types/questions.js").QuestionItem[] | undefined,
): import("../types/questions.js").QuestionItem[] | undefined {
if (!questions?.length) return undefined;
const fresh = dropAlreadyAskedQuestions(questions, this.askedQuestionHistory());
if (!fresh.length) return undefined;
this.rememberAskedQuestions(fresh);
return fresh;
}
/** /**
* 验收态下作答或跳过挂载追问:不离开 review_artifact。 * 验收态下作答或跳过挂载追问:不离开 review_artifact。
* answersText 缺省 = 跳过。 * answersText 缺省 = 跳过。
@@ -662,6 +761,13 @@ export class PhaseRuntime {
/** 消费 applyEvent 返回的 PhaseEffect 列表 */ /** 消费 applyEvent 返回的 PhaseEffect 列表 */
private async processEffects(effects: PhaseEffect[]): Promise<void> { private async processEffects(effects: PhaseEffect[]): Promise<void> {
for (const effect of effects) { for (const effect of effects) {
if (effect.type === "run_worker") {
debugLog("step", `启动执行单元 ${effect.workerId}`);
} else if (effect.type === "propose_next_creation_step") {
debugLog("step", "提案下一步");
} else if (effect.type === "resume_worker") {
debugLog("step", `恢复执行单元 ${this.session.resumeContext?.workerId ?? "未知"}`);
}
switch (effect.type) { switch (effect.type) {
case "emit_message": case "emit_message":
this.onMessage(effect.message); this.onMessage(effect.message);
@@ -705,10 +811,209 @@ export class PhaseRuntime {
case "run_programmatic_review": case "run_programmatic_review":
await this.runProgrammaticReview(effect.artifactId); await this.runProgrammaticReview(effect.artifactId);
break; break;
case "propose_next_creation_step":
await this.proposeNextCreationStep();
break;
} }
} }
} }
/**
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
*/
private async proposeNextCreationStep(): Promise<void> {
this.syncSlotsToBlackboard(this.session);
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const accepted = parseAcceptedUnits(
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
const pending = nextPendingStep(flow, accepted);
if (!pending || !flow) {
debugLog("step", "暂无下一步,交给总管");
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: undefined,
},
};
if (this.mainAgent) {
await this.runMainAgent();
} else {
this.onMessage(
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
);
}
return;
}
const active = this.getActiveSkill();
let paramSpecs: ModuleParamSpec[] = [];
let declaration = "";
if (active?.name) {
try {
const skill = await loadSkill(active.name);
if (skill.skillPackRoot) {
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const mod = catalog?.modules.find((m) => m.name === pending.name);
paramSpecs = mod?.params ?? [];
declaration = mod?.declaration?.trim() || "";
}
} catch {
/* catalog optional */
}
}
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 intent =
typeof this.session.slots["用户.下一步意向"] === "string"
? String(this.session.slots["用户.下一步意向"]).trim()
: "";
const paramHint =
Object.keys(params).length > 0
? `;参数 ${Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join(" · ")}`
: paramsMissing.length
? `;待钉参数:${paramsMissing.join("、")}`
: "";
const reason = [
`下一步:${pending.name}`,
declaration ? `—— ${declaration}` : "",
paramHint,
intent ? `(你的意向:${intent}` : "",
]
.filter(Boolean)
.join("");
const snapshot = {
stepId: pending.id,
name: pending.name,
declaration: declaration || undefined,
params,
paramsMissing,
paramSpecs: paramSpecs.map((p) => ({
key: p.key,
label: p.label,
required: Boolean(p.required),
hint: p.hint,
})),
intent: intent || undefined,
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
},
};
const decision = createDecision({
action: "run_worker",
workerId: "design-step",
reason,
requiresApproval: true,
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
}
private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
: null) ||
this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
if (!raw?.trim()) return;
let snap: {
stepId?: string;
paramSpecs?: Array<{ key: string; required?: boolean }>;
};
try {
snap = JSON.parse(raw) as typeof snap;
} catch {
return;
}
const stepId = typeof snap.stepId === "string" ? snap.stepId.trim() : "";
if (!stepId) return;
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return;
const next = patchCreationFlowStepParams(flow, stepId, params);
const content = stringifyCreationFlow(next);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
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 snapshot = {
...snap,
params,
paramsMissing,
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_FLOW_TAG]: content,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
},
};
}
private assertProposedStepReady(): string | null {
const decision = this.session.pendingDecision;
if (decision?.workerId !== "design-step") return null;
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
: null) ||
this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
if (!raw?.trim()) return null;
try {
const snap = JSON.parse(raw) as { paramsMissing?: string[] };
if (snap.paramsMissing?.length) {
return `请先补齐必填参数:${snap.paramsMissing.join("、")}`;
}
} catch {
/* ignore */
}
return null;
}
private getInstanceWorkerDeclaration() { private getInstanceWorkerDeclaration() {
return buildInstanceWorkerDeclaration( return buildInstanceWorkerDeclaration(
this.session, this.session,
@@ -840,24 +1145,50 @@ export class PhaseRuntime {
if (result.askUser?.length && Object.keys(result.outputs).length === 0) { if (result.askUser?.length && Object.keys(result.outputs).length === 0) {
// 无产物:阻塞追问(信息不足,必须补) // 无产物:阻塞追问(信息不足,必须补)
if (await this.restoreRevisionTargetForReview(workerId, result.askUser)) {
return;
}
await this.workerAsk(result.askUser); await this.workerAsk(result.askUser);
return; return;
} }
if (
workerId === "design-step" &&
Object.keys(result.outputs).length === 0
) {
const retryQuestions = result.askUser?.length
? result.askUser
: [
{
id: "design-step-retry",
prompt:
"这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。",
allowOther: true,
required: true,
},
];
if (await this.restoreRevisionTargetForReview(workerId, retryQuestions)) {
return;
}
await this.workerAsk(retryQuestions);
return;
}
slots = { ...this.session.slots }; slots = { ...this.session.slots };
const writtenTags: string[] = [];
for (const [tag, content] of Object.entries(result.outputs)) { for (const [tag, content] of Object.entries(result.outputs)) {
if (isProgressPointerTag(tag)) continue;
const written = this.writeWorkerTagContent(tag, content, workerId); const written = this.writeWorkerTagContent(tag, content, workerId);
slots[tag] = written; slots[tag] = written;
writtenTags.push(tag);
} }
this.session = { ...this.session, slots }; this.session = { ...this.session, slots };
const artifact = createArtifact({ const artifact = createArtifact({
workerId: effect.workerId, workerId: effect.workerId,
stepId: this.session.currentStepId, stepId: this.session.currentStepId,
outputTags: outputTags: (writtenTags.length > 0 ? writtenTags : worker.outputTags).filter(
Object.keys(result.outputs).length > 0 (t) => !isProgressPointerTag(t),
? Object.keys(result.outputs) ),
: worker.outputTags,
summary: result.summary, summary: result.summary,
}); });
this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] }; this.session = { ...this.session, artifacts: [...this.session.artifacts, artifact] };
@@ -871,7 +1202,7 @@ export class PhaseRuntime {
payload: { payload: {
artifactId: artifact.id, artifactId: artifact.id,
// 有产物时 askUser 挂到验收态,不阻断 Accept // 有产物时 askUser 挂到验收态,不阻断 Accept
questions: result.askUser?.length ? result.askUser : undefined, questions: this.freshSidecarQuestions(result.askUser),
assessment: result.askAssessment?.trim() || undefined, assessment: result.askAssessment?.trim() || undefined,
}, },
}); });
@@ -1054,6 +1385,22 @@ export class PhaseRuntime {
} }
} }
} }
if (tag === CREATION_FLOW_TAG) {
const merged = mergeCreationFlowPreservingAccepted({
prevRaw: this.blackboard.getContentByTag(tag),
nextRaw: toWrite,
acceptedStepIds: parseAcceptedUnits(
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
),
});
toWrite = merged.raw;
if (merged.restored.length) {
this.onMessage(
`[流程编排] 已验收步骤按原样保留:${merged.restored.join("、")}`,
);
}
}
this.blackboard.write({ this.blackboard.write({
tag, tag,
content: toWrite, content: toWrite,
@@ -1252,6 +1599,48 @@ export class PhaseRuntime {
if (phase === "answered") return false; if (phase === "answered") return false;
// 配方开局模块 + 用户首句已在「用户.需求」→ 默认视为 opening 已做完,直接进 LLM
const demand = (
this.blackboard.getContentByTag("用户.需求") ??
this.session.slots["用户.需求"] ??
""
)
.toString()
.trim();
if (
shouldSkipModuleOpening({
demand,
dependsOn: binding.step.depends_on,
phase,
})
) {
state[stepKey] = "answered";
const raw = stringifyModuleOpeningState(state);
this.blackboard.write({
tag: CREATION_MODULE_OPENING_STATE_TAG,
content: raw,
source: "runtime",
});
this.blackboard.write({
tag: CREATION_MODULE_OPENING_TAG,
content: "(已跳过默认问题:用户首句见「用户.需求」)",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[SLOT_CREATION_MODULE_OPENING_STATE]: raw,
[CREATION_MODULE_OPENING_TAG]:
"(已跳过默认问题:用户首句见「用户.需求」)",
},
};
this.onMessage(
`[系统] ${binding.module.name} · 已坐在配方开局步,首句见「用户.需求」,跳过默认问题`,
);
return false;
}
if (phase === "shown" && this.session.resumeContext) { if (phase === "shown" && this.session.resumeContext) {
// 用户刚答完默认问题 → 标记已答,继续走 LLM // 用户刚答完默认问题 → 标记已答,继续走 LLM
state[stepKey] = "answered"; state[stepKey] = "answered";
@@ -1315,15 +1704,13 @@ export class PhaseRuntime {
const questions = normalizeQuestions([ const questions = normalizeQuestions([
{ {
id: "module-opening", id: MODULE_OPENING_QUESTION_ID,
prompt: binding.opening.trim(), prompt: binding.opening.trim(),
allowOther: true, allowOther: true,
required: true, required: true,
}, },
]); ]);
this.onMessage( // 正文由说话面 openingGuide 展示对齐美学纲领workerAsk 只发短调度句
`[Worker] ${binding.module.name} · 默认问题(程序):\n${binding.opening.trim()}`,
);
await this.workerAsk(questions); await this.workerAsk(questions);
return true; return true;
} }
@@ -1356,6 +1743,8 @@ export class PhaseRuntime {
if (!current) current = "flow"; if (!current) current = "flow";
debugLog("step", `钉住当前步骤 ${workerId}${current}`);
const anchorAt = new Date().toISOString(); const anchorAt = new Date().toISOString();
this.blackboard.write({ this.blackboard.write({
tag: CREATION_CURRENT_UNIT_TAG, tag: CREATION_CURRENT_UNIT_TAG,
@@ -1386,17 +1775,29 @@ export class PhaseRuntime {
const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim(); const fromSlot = String(slots[SLOT_CREATION_CURRENT_UNIT] ?? "").trim();
const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim(); const fromStep = this.blackboard.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim();
const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim(); const fromSummary = artifact.summary?.match(/单位\s+([^\s·]+)/)?.[1]?.trim();
const unitId = // 接受工作流计划 ≠ 某一步做完「flow」不得写入已验收步骤
(artifact.workerId === "design-step" ? fromStep : "") || if (artifact.workerId === "design-flow") return;
fromBoard ||
fromSlot ||
fromSummary ||
"";
const prev = parseAcceptedUnits( const prev = parseAcceptedUnits(
slots[SLOT_CREATION_ACCEPTED_UNITS] ?? slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
); );
const unitId =
artifact.workerId === "design-step"
? pickRecordedStepId({
flow: parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
),
alreadyAccepted: prev,
pinnedUnitId: fromSlot || fromBoard,
writtenCurrentStep: fromStep,
summaryHint: fromSummary,
}) ?? ""
: fromBoard || fromSlot || fromSummary || "";
if (!unitId || unitId === "flow") return;
const next = unitId && !prev.includes(unitId) ? [...prev, unitId] : prev; const next = unitId && !prev.includes(unitId) ? [...prev, unitId] : prev;
if (next !== prev) {
debugLog("step", `验收步骤 ${unitId} 已验收 ${next.join("、")}`);
}
slots[SLOT_CREATION_ACCEPTED_UNITS] = next; slots[SLOT_CREATION_ACCEPTED_UNITS] = next;
slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined; slots[SLOT_CREATION_CURRENT_UNIT] = unitId || undefined;
this.blackboard.write({ this.blackboard.write({
@@ -1518,6 +1919,7 @@ export class PhaseRuntime {
return; return;
} }
debugLog("step", "总管开始");
const availableWorkers = await this.resolveAvailableWorkers(); const availableWorkers = await this.resolveAvailableWorkers();
const decision = await this.mainAgent.runToolLoop( const decision = await this.mainAgent.runToolLoop(
@@ -1554,13 +1956,16 @@ export class PhaseRuntime {
}, },
); );
debugLog(
"step",
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`); this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = applyEvent(this.session, { const result = this.commitEvent({
type: "main_agent_decision_created", type: "main_agent_decision_created",
payload: { decision }, payload: { decision },
}); });
this.session = result.session;
if (result.error) { if (result.error) {
this.onMessage(result.error); this.onMessage(result.error);

View File

@@ -12,7 +12,12 @@ import {
displayWorkerLabel, displayWorkerLabel,
formatAgentDisplayTitle, formatAgentDisplayTitle,
formatWorkerDisplayTitle, formatWorkerDisplayTitle,
reviewComposerCopy,
} from "./display-labels.js"; } from "./display-labels.js";
import {
isModuleOpeningQuestions,
normalizeQuestions,
} from "../skills/question-protocol.js";
export type AgentMessageKind = export type AgentMessageKind =
| "user_input" | "user_input"
@@ -167,6 +172,20 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
}; };
} }
// 能力默认问题:正文在主栏说话面,调度消息只留短提示
const moduleOpeningMsg = trimmed.match(
/^\[Worker\]\s*(\S+)\s*·\s*默认问题/,
);
if (moduleOpeningMsg) {
return {
kind: "orchestrator_prompt",
actor: moduleOpeningMsg[1],
title: formatWorkerDisplayTitle(moduleOpeningMsg[1], "questions"),
body: "默认问题(请在主栏按引导作答)",
text: trimmed,
};
}
const workerAskTagged = trimmed.match( const workerAskTagged = trimmed.match(
/^\[Worker\]\s*(\S+)\s*提问[:]\s*\n?([\s\S]*)$/, /^\[Worker\]\s*(\S+)\s*提问[:]\s*\n?([\s\S]*)$/,
); );
@@ -240,7 +259,7 @@ export function classifyAgentMessage(text: string): EnrichedMessage {
title: "可选追问", title: "可选追问",
body: formatWorkerQuestionBody( body: formatWorkerQuestionBody(
trimmed.replace( trimmed.replace(
/^\[Worker\]\s*可选追问(可跳过,直接接受(?:目前)?产物[:]\s*/, /^\[Worker\]\s*可选追问(可跳过(?:,直接[^]+)?[:]\s*/,
"", "",
), ),
), ),
@@ -387,19 +406,52 @@ export function buildFocus(
if (reason?.kind === "approve_step") { if (reason?.kind === "approve_step") {
const worker = session.pendingDecision?.workerId ?? "skill"; const worker = session.pendingDecision?.workerId ?? "skill";
const proposed =
typeof session.slots["创作.待确认步骤"] === "string"
? (() => {
try {
return JSON.parse(String(session.slots["创作.待确认步骤"])) as {
name?: string;
};
} catch {
return null;
}
})()
: null;
return { return {
actorType: "orchestrator", actorType: "orchestrator",
actorId: "orchestrator", actorId: "orchestrator",
actorLabel: "总管", actorLabel: "编排",
action: `建议调用 ${displayWorkerLabel(worker)}`, action: proposed?.name
? `确认开始 · ${proposed.name}`
: `建议调用 ${displayWorkerLabel(worker)}`,
detail: session.pendingDecision?.reason, detail: session.pendingDecision?.reason,
}; };
} }
if (reason?.kind === "next_intent") {
return {
actorType: "user",
actorLabel: "你",
action: "下一步想写什么",
detail: "可留空;发送后会展示下一节点供确认开干",
};
}
if (reason?.kind === "worker_questions") { if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
return {
actorType: "user",
actorId: reason.workerId,
actorLabel: "你",
action: "按引导先说几句",
detail: (qs[0]?.prompt ?? "").slice(0, 200) || "想到什么写什么",
};
}
const q = const q =
reason.questions qs
?.map((item) => item.prompt) .map((item) => item.prompt)
.filter((s) => s?.trim()) .filter((s) => s?.trim())
.join("") ?? ""; .join("") ?? "";
return { return {
@@ -413,13 +465,16 @@ export function buildFocus(
if (reason?.kind === "review_artifact") { if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
});
const optionalQs = reason.questions?.length const optionalQs = reason.questions?.length
? `;另有 ${reason.questions.length} 道可选追问` ? `;另有 ${reason.questions.length} 道可选追问`
: ""; : "";
return { return {
actorType: "user", actorType: "user",
actorLabel: "你", actorLabel: "你",
action: "验收产物", action: copy.taskTitle,
detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`, detail: `${art?.summary ?? displayWorkerLabel(art?.workerId) ?? ""}${optionalQs}`,
}; };
} }

View File

@@ -122,3 +122,84 @@ export function formatAgentDisplayTitle(detail?: string): string {
if (detail?.trim()) return `编排器 · ${detail.trim()}`; if (detail?.trim()) return `编排器 · ${detail.trim()}`;
return "编排器"; return "编排器";
} }
/** 底栏双按钮 + 工作面 kicker随 waiting 产物种类变化 */
export type ReviewComposerCopy = {
/** 底栏短芯片 */
chip: string;
/** 工作面左上角 */
kicker: string;
taskLabel: string;
taskTitle: string;
hint: string;
submitLabel: string;
acceptLabel: string;
placeholder: string;
emptyEnterHint: string;
/** plan编排确认主色accept产物验收绿 */
tone: "plan" | "accept";
kind: "flow" | "opening" | "accept";
};
/**
* `review_artifact` 的用户文案。流程编排核对的是工作流计划,不是「验收通过」。
*/
export function reviewComposerCopy(
workerId: string | undefined | null,
opts?: { hasQuestions?: boolean },
): ReviewComposerCopy {
const hasQuestions = Boolean(opts?.hasQuestions);
const id = (workerId ?? "").trim();
if (id === "design-flow") {
return {
chip: "编排",
kicker: "核对编排",
taskLabel: "编排",
taskTitle: "核对工作流计划",
hint: hasQuestions
? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。"
: "同意就按这个排;要改就写意见,在现有计划上改。",
submitLabel: "按意见改编排",
acceptLabel: "确认编排",
placeholder: "改编排意见…",
emptyEnterHint: "空 Enter确认编排",
tone: "plan",
kind: "flow",
};
}
if (id === "opening-generator") {
return {
chip: "开场",
kicker: "待选定",
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter选定此开场",
tone: "plan",
kind: "opening",
};
}
return {
chip: "验收",
kicker: "待验收",
taskLabel: "验收",
taskTitle: "验收产物",
hint: hasQuestions
? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。"
: "同意就接受;要改就写意见,在现有产物上改。",
submitLabel: "按意见修改",
acceptLabel: "接受",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter接受",
tone: "accept",
kind: "accept",
};
}

View File

@@ -63,7 +63,12 @@ import type { BlackboardItem } from "../types/blackboard.js";
import { Blackboard } from "../blackboard/blackboard.js"; import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js"; import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import { parseAcceptedUnits, isDesignDiskWorker } from "../skills/creation-units.js"; import {
CREATION_ACCEPTED_UNITS_TAG,
isDesignDiskWorker,
parseAcceptedUnits,
SLOT_CREATION_ACCEPTED_UNITS,
} from "../skills/creation-units.js";
import { import {
formatWorkerSetForUser, formatWorkerSetForUser,
type WorkerSetUserView, type WorkerSetUserView,
@@ -78,13 +83,21 @@ import {
type ContextOrderEdit, type ContextOrderEdit,
} from "../skills/context-order.js"; } from "../skills/context-order.js";
import { import {
CREATION_CURRENT_STEP_TAG,
CREATION_FLOW_TAG, CREATION_FLOW_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG, CREATION_SELECTED_RECIPE_TAG,
creationFlowFromRecipeSeed,
findRecipeCatalogEntry, findRecipeCatalogEntry,
formatCreationFlowForUser, formatCreationFlowForUser,
isProgressPointerTag,
loadRecipeCatalog, loadRecipeCatalog,
loadRecipeDetail,
parseCreationFlow, parseCreationFlow,
parseSelectedRecipeRef, parseSelectedRecipeRef,
resolveDesignStepBinding,
stepUnitId,
stringifyCreationFlow,
type CreationFlowUserView, type CreationFlowUserView,
type RecipeCatalogEntry, type RecipeCatalogEntry,
} from "../skills/creation-flow.js"; } from "../skills/creation-flow.js";
@@ -94,6 +107,7 @@ import { buildBoardPanel } from "../runtime/compress-after-worker.js";
import { import {
displayWorkerLabel, displayWorkerLabel,
formatWorkerDisplayTitle, formatWorkerDisplayTitle,
reviewComposerCopy,
} from "./display-labels.js"; } from "./display-labels.js";
import { import {
buildCreationDialogueTranscript, buildCreationDialogueTranscript,
@@ -102,6 +116,7 @@ import {
import { import {
formatQuestionAnswersForAi, formatQuestionAnswersForAi,
formatQuestionAnswersForDisplay, formatQuestionAnswersForDisplay,
isModuleOpeningQuestions,
normalizeQuestions, normalizeQuestions,
} from "../skills/question-protocol.js"; } from "../skills/question-protocol.js";
import type { QuestionAnswer } from "../types/questions.js"; import type { QuestionAnswer } from "../types/questions.js";
@@ -180,10 +195,16 @@ export type SessionView = {
workerSetView?: WorkerSetUserView; workerSetView?: WorkerSetUserView;
/** 创作流程用户视图(步骤顺序 + 依赖) */ /** 创作流程用户视图(步骤顺序 + 依赖) */
creationFlowView?: CreationFlowUserView; creationFlowView?: CreationFlowUserView;
/** 确认开干:下一步提案(含可编辑编排参数) */
proposedNextStep?: ProposedNextStepView;
/** 能力包内可选初始配方(用户手动选) */ /** 能力包内可选初始配方(用户手动选) */
recipes?: Array<{ id: string; name: string; declaration: string }>; recipes?: Array<{ id: string; name: string; declaration: string }>;
/** 用户已选初始配方 */ /** 用户已选初始配方 */
selectedRecipe?: { id: string; name: string; declaration: string } | null; selectedRecipe?: { id: string; name: string; declaration: string } | null;
/**
* 开局引导:配方起点或当前能力默认问题的 opening说话面主柱对齐美学纲领
*/
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 黑板与定稿上下文(强可读) */ /** 黑板与定稿上下文(强可读) */
boardPanel?: BoardPanelView; boardPanel?: BoardPanelView;
/** 游玩呈现壳微调(来自设计.正文组成) */ /** 游玩呈现壳微调(来自设计.正文组成) */
@@ -238,6 +259,21 @@ export type BoardPanelView = {
brief?: string; brief?: string;
}; };
export type ProposedNextStepView = {
stepId: string;
name: string;
declaration?: string;
params: Record<string, unknown>;
paramsMissing: string[];
paramSpecs: Array<{
key: string;
label: string;
required: boolean;
hint?: string;
}>;
intent?: string;
};
export type ReviewArtifactView = { export type ReviewArtifactView = {
id: string; id: string;
workerId: string; workerId: string;
@@ -263,14 +299,18 @@ type ManagedSession = {
branchState: MessageBranchState; branchState: MessageBranchState;
/** 当前能力包可选初始配方(用户手动选) */ /** 当前能力包可选初始配方(用户手动选) */
recipeOptions?: RecipeCatalogEntry[]; recipeOptions?: RecipeCatalogEntry[];
/** 开局模块 opening缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
}; };
export class SessionManager { export class SessionManager {
private readonly sessions = new Map<string, ManagedSession>(); private readonly sessions = new Map<string, ManagedSession>();
/** bookId → 当前内存中的 sessionId */ /** bookId → 当前内存中的 sessionId */
private readonly activeBookSessions = new Map<string, string>(); private readonly activeBookSessions = new Map<string, string>();
/** sessionId → 流式 thinking 缓冲 */ /** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */
private readonly agentThinkingLive = new Map<string, string>(); private readonly agentThinkingLive = new Map<string, string>();
/** 本轮思维链已收束;下一次 delta 起新一轮 */
private readonly agentThinkingClosed = new Set<string>();
/** sessionId → worker 流式缓冲 */ /** sessionId → worker 流式缓冲 */
private readonly workerLive = new Map< private readonly workerLive = new Map<
string, string,
@@ -679,7 +719,11 @@ export class SessionManager {
return count; return count;
} }
async sendMessage(id: string, text: string): Promise<SessionView> { async sendMessage(
id: string,
text: string,
opts?: { answers?: QuestionAnswer[] },
): Promise<SessionView> {
const s = this.require(id); const s = this.require(id);
this.clearAgentThinking(id); this.clearAgentThinking(id);
recordPreMessageCheckpoint( recordPreMessageCheckpoint(
@@ -687,21 +731,62 @@ export class SessionManager {
s.messages.length, s.messages.length,
this.captureCheckpoint(s), this.captureCheckpoint(s),
); );
s.messages.push(this.msg("user", text));
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
const reason = s.runtime.getSession().waitingReason; const reason = s.runtime.getSession().waitingReason;
const trimmed = text.trim();
const sidecarAnswers =
reason?.kind === "review_artifact" &&
reason.questions?.length &&
opts?.answers?.length
? opts.answers.filter((a) => a.text?.trim() && a.text !== "(未答)")
: [];
// next_intent 允许留空:不塞空白气泡
if (trimmed || reason?.kind !== "next_intent" || sidecarAnswers.length) {
let recordText = trimmed || text;
let displayBody: string | undefined;
if (sidecarAnswers.length && reason?.kind === "review_artifact") {
const qs = normalizeQuestions(reason.questions ?? []);
recordText = formatQuestionAnswersForAi(
qs,
sidecarAnswers,
trimmed || undefined,
);
displayBody = formatQuestionAnswersForDisplay(
qs,
sidecarAnswers,
trimmed || undefined,
);
}
const userMsg = this.msg("user", recordText);
if (displayBody) userMsg.body = displayBody;
s.messages.push(userMsg);
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
try { try {
if (reason?.kind === "approve_step") { if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(text); await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") { } else if (reason?.kind === "review_artifact") {
await s.runtime.rejectArtifact(text); // 先把卡上追问(问+选项+补充)写入「用户.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 { } else {
await s.runtime.submitInput(text); await s.runtime.submitInput(trimmed || text);
} }
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[session] sendMessage failed:", detail); console.error("[会话] 发送消息失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally { } finally {
@@ -755,12 +840,14 @@ export class SessionManager {
try { try {
if (isSidecar) { if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText); await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else { } else {
await s.runtime.submitInput(aiText); await s.runtime.submitInput(aiText);
} }
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[session] answerQuestions failed:", detail); console.error("[会话] 回答追问失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} finally { } finally {
@@ -960,13 +1047,16 @@ export class SessionManager {
return this.toView(id); return this.toView(id);
} }
async approve(id: string): Promise<SessionView> { async approve(
id: string,
stepParams?: Record<string, unknown>,
): Promise<SessionView> {
const s = this.require(id); const s = this.require(id);
try { try {
await s.runtime.approve(); await s.runtime.approve(stepParams);
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[session] approve failed:", detail); console.error("[会话] 确认开干失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
} }
return this.toView(id); return this.toView(id);
@@ -978,7 +1068,7 @@ export class SessionManager {
await s.runtime.confirmIntake(); await s.runtime.confirmIntake();
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[session] confirmIntake failed:", detail); console.error("[会话] 确认需求失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} }
@@ -1028,7 +1118,7 @@ export class SessionManager {
} }
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[session] reject failed:", detail); console.error("[会话] 打回失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} }
@@ -1154,7 +1244,7 @@ export class SessionManager {
payload: { text }, payload: { text },
}); });
} catch (err) { } catch (err) {
console.error("[session] maybeKickAgentAfterDemand failed:", err); console.error("[会话] 需求后唤醒总管失败", err);
} }
} }
@@ -1214,7 +1304,7 @@ export class SessionManager {
const transcript = buildCreationDialogueTranscript(s.messages); const transcript = buildCreationDialogueTranscript(s.messages);
s.runtime.writeCreationDialogue(transcript); s.runtime.writeCreationDialogue(transcript);
} catch (err) { } catch (err) {
console.warn("[session] syncCreationDialogue:", err); console.warn("[会话] 同步创作对话失败", err);
} }
} }
@@ -1274,6 +1364,7 @@ export class SessionManager {
private clearAgentThinking(sessionId: string): void { private clearAgentThinking(sessionId: string): void {
this.agentThinkingLive.delete(sessionId); this.agentThinkingLive.delete(sessionId);
this.agentThinkingClosed.delete(sessionId);
this.workerLive.delete(sessionId); this.workerLive.delete(sessionId);
} }
@@ -1287,15 +1378,20 @@ export class SessionManager {
| "onWorkerStreamStart" | "onWorkerStreamStart"
| "onWorkerThinkingDelta" | "onWorkerThinkingDelta"
| "onWorkerOutputDelta" | "onWorkerOutputDelta"
| "onWorkerStreamDone"
> { > {
return { return {
onAgentThinkingDelta: (delta) => { onAgentThinkingDelta: (delta) => {
const prev = this.agentThinkingLive.get(sessionId) ?? ""; const prev = this.agentThinkingClosed.has(sessionId)
? ""
: (this.agentThinkingLive.get(sessionId) ?? "");
this.agentThinkingClosed.delete(sessionId);
this.agentThinkingLive.set(sessionId, prev + delta); this.agentThinkingLive.set(sessionId, prev + delta);
}, },
onAgentThinkingDone: () => { onAgentThinkingDone: (text) => {
this.agentThinkingLive.delete(sessionId); // 正文仍等完整后再渲染;思考收束后继续挂在 liveStream避免中间只剩「…」
this.agentThinkingClosed.add(sessionId);
const done = text.trim();
if (done) this.agentThinkingLive.set(sessionId, done);
}, },
onWorkerStreamStart: (workerId) => { onWorkerStreamStart: (workerId) => {
this.workerLive.set(sessionId, { workerId, thinking: "", output: "" }); this.workerLive.set(sessionId, { workerId, thinking: "", output: "" });
@@ -1322,9 +1418,6 @@ export class SessionManager {
prev.output += delta; prev.output += delta;
this.workerLive.set(sessionId, prev); this.workerLive.set(sessionId, prev);
}, },
onWorkerStreamDone: () => {
this.workerLive.delete(sessionId);
},
}; };
} }
@@ -1603,47 +1696,94 @@ export class SessionManager {
} }
} else if (reason?.kind === "worker_questions") { } else if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions); const qs = normalizeQuestions(reason.questions);
if (qs.length) { if (isModuleOpeningQuestions(qs)) {
// 能力默认问题:对齐美学纲领 → 自由书写,不是可跳过追问卡
hints.push("按主栏引导先说几句(想到什么写什么,不必整齐)");
actions.push({
type: "send_message",
label: "发送",
placeholder: "想到什么写什么…",
});
} else if (qs.length) {
hints.push( hints.push(
`${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`, `${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`,
); );
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
actions.push({
type: "send_message",
label: "自由补充",
placeholder: "也可在此自由补充…",
});
} else { } else {
hints.push( hints.push(
`${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`, `${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`,
); );
actions.push({
type: "send_message",
label: "发送",
placeholder: "补充说明…",
});
} }
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
actions.push({
type: "send_message",
label: "自由补充",
placeholder: "也可在此自由补充…",
});
} else if (reason?.kind === "approve_step") { } else if (reason?.kind === "approve_step") {
actions.push({ type: "approve", label: "确认执行" }); const proposed = readProposedNextStep(
s.runtime.getSession().slots,
s.runtime.getBlackboard(),
);
if (proposed) {
hints.push(
`确认开始「${proposed.name}${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")}`
: ""
}`,
);
actions.push({
type: "approve",
label: proposed.paramsMissing.length ? "补参后开始" : "同意并开始",
});
} else {
actions.push({ type: "approve", label: "确认执行" });
}
actions.push({ type: "reject", label: "暂不执行" }); actions.push({ type: "reject", label: "暂不执行" });
} else if (reason?.kind === "review_artifact") {
actions.push({ type: "accept", label: "接受目前产物" });
actions.push({ type: "reject", label: "不接受,重新来" });
actions.push({ actions.push({
type: "send_message", type: "send_message",
label: "发送修改意见", label: "说明意见",
placeholder: "输入修改意见后发送…", placeholder: "不同意可说明要改成什么…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空,发送后会展示下一节点供你确认。");
actions.push({
type: "send_message",
label: "继续",
placeholder: "下一步想写什么?(可留空)…",
});
} else if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
});
actions.push({ type: "accept", label: copy.acceptLabel });
actions.push({ type: "reject", label: "不接受" });
actions.push({
type: "send_message",
label: copy.submitLabel,
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
}); });
if (reason.questions?.length) { if (reason.questions?.length) {
hints.push("下方追问可选;接受产物即收起(表示无需再完善)"); hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
actions.push({ type: "answer_questions", label: "提交作答" }); actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" }); actions.push({ type: "skip_questions", label: "跳过追问" });
} }
} else if (reason?.kind === "revision") { } else if (reason?.kind === "revision") {
hints.push( hints.push(
reason.instruction?.trim() || reason.instruction?.trim() ||
"需要按反馈修改后继续;请在下方说明或直接发送继续", "请说明要改哪里;发送后在现有产物上修改",
); );
actions.push({ actions.push({
type: "send_message", type: "send_message",
label: "发送", label: "按意见修改",
placeholder: "说明修改意见,或直接发送让总管继续…", placeholder: "说明要改哪里…",
}); });
} else if (session.phase === "error") { } else if (session.phase === "error") {
hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。"); hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。");
@@ -1737,7 +1877,17 @@ export class SessionManager {
s.runtime.getBlackboard(), s.runtime.getBlackboard(),
s.runtime.getSession(), s.runtime.getSession(),
), ),
creationFlowView: buildCreationFlowUserView(s.runtime.getBlackboard()), creationFlowView: buildCreationFlowUserView(
s.runtime.getBlackboard(),
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
),
proposedNextStep:
reason?.kind === "approve_step"
? readProposedNextStep(
session.slots,
s.runtime.getBlackboard(),
)
: undefined,
recipes: s.recipeOptions?.map((r) => ({ recipes: s.recipeOptions?.map((r) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -1747,6 +1897,7 @@ export class SessionManager {
s.runtime.getBlackboard(), s.runtime.getBlackboard(),
s.recipeOptions, s.recipeOptions,
), ),
openingGuide: this.resolveOpeningGuideForView(s, reason),
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()), boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
presentationTweaks: shellAdapt presentationTweaks: shellAdapt
? { ? {
@@ -1839,12 +1990,14 @@ export class SessionManager {
const skillName = s.runtime.getActiveSkill()?.name; const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) { if (!skillName) {
s.recipeOptions = []; s.recipeOptions = [];
s.openingGuide = null;
return; return;
} }
try { try {
const skill = await loadSkill(skillName); const skill = await loadSkill(skillName);
if (!skill.skillPackRoot) { if (!skill.skillPackRoot) {
s.recipeOptions = []; s.recipeOptions = [];
s.openingGuide = null;
return; return;
} }
const catalog = await loadRecipeCatalog(skill.skillPackRoot); const catalog = await loadRecipeCatalog(skill.skillPackRoot);
@@ -1852,6 +2005,125 @@ export class SessionManager {
} catch { } catch {
s.recipeOptions = []; s.recipeOptions = [];
} }
await this.ensureRecipeSeededFlow(s);
await this.refreshOpeningGuide(s);
}
/** 已选配方但尚无流程 → 写入配方近期起点(兼容旧会话) */
private async ensureRecipeSeededFlow(s: ManagedSession): Promise<void> {
const board = s.runtime.getBlackboard();
if (board.getContentByTag(CREATION_FLOW_TAG)?.trim()) return;
const ref = parseSelectedRecipeRef(
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
);
if (!ref) return;
const entry = findRecipeCatalogEntry(
s.recipeOptions?.length ? { recipes: s.recipeOptions } : null,
ref,
);
if (!entry) return;
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) return;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return;
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (!seeded) return;
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
} catch (err) {
console.error("[会话] 配方预置失败", err);
}
}
/**
* 下一待做开局步(无 depends_on的 opening → 意图页引导。
* 有起点模块就展示;无则 null再走 design-flow 生成)。
* 中段步骤的默认问题改由 resolveOpeningGuideForView 在 waiting 时覆盖。
*/
private async refreshOpeningGuide(s: ManagedSession): Promise<void> {
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) {
s.openingGuide = null;
return;
}
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) {
s.openingGuide = null;
return;
}
const board = s.runtime.getBlackboard();
const session = s.runtime.getSession();
const accepted = parseAcceptedUnits(
session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
board.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
const binding = await resolveDesignStepBinding({
skillPackRoot: packRoot,
flowRaw: board.getContentByTag(CREATION_FLOW_TAG),
acceptedStepNames: accepted,
});
if (
!binding?.opening?.trim() ||
(binding.step.depends_on?.length ?? 0) > 0
) {
s.openingGuide = null;
return;
}
s.openingGuide = {
stepId: binding.step.id,
stepName: binding.module.name || binding.step.name,
text: binding.opening.trim(),
};
} catch {
s.openingGuide = null;
}
}
/**
* 视图用开场引导:等待能力默认问题时用题干(对齐美学纲领说话面);
* 中段步骤(有 depends_on也会覆盖避免只剩空答题壳。
*/
private resolveOpeningGuideForView(
s: ManagedSession,
reason: WaitingReason | undefined,
): { stepId: string; stepName: string; text: string } | null {
if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
const text = qs[0]?.prompt?.trim() ?? "";
if (text) {
const board = s.runtime.getBlackboard();
const stepKey =
board.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() || "";
const flow = parseCreationFlow(board.getContentByTag(CREATION_FLOW_TAG));
const step = flow?.steps?.find(
(st) =>
stepUnitId(st) === stepKey ||
st.id === stepKey ||
st.name === stepKey,
);
const stepName =
step?.name?.trim() ||
s.openingGuide?.stepName?.trim() ||
stepKey ||
"按引导先说几句";
return {
stepId: qs[0]!.id,
stepName,
text,
};
}
}
}
return s.openingGuide ?? null;
} }
private async applySelectedRecipe( private async applySelectedRecipe(
@@ -1866,7 +2138,8 @@ export class SessionManager {
if (!entry) { if (!entry) {
throw new Error(`未知初始配方:${recipeId}`); throw new Error(`未知初始配方:${recipeId}`);
} }
s.runtime.getBlackboard().write({ const board = s.runtime.getBlackboard();
board.write({
tag: CREATION_SELECTED_RECIPE_TAG, tag: CREATION_SELECTED_RECIPE_TAG,
content: JSON.stringify({ content: JSON.stringify({
id: entry.id, id: entry.id,
@@ -1874,6 +2147,33 @@ export class SessionManager {
}), }),
source: "user", source: "user",
}); });
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!existingFlow) {
const skillName = s.runtime.getActiveSkill()?.name;
if (skillName) {
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (packRoot) {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (seeded) {
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
}
}
} catch (err) {
console.error("[会话] 从配方写入创作流程失败", err);
}
}
}
await this.refreshOpeningGuide(s);
} }
} }
@@ -1893,6 +2193,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
function buildCreationFlowUserView( function buildCreationFlowUserView(
blackboard: Blackboard, blackboard: Blackboard,
acceptedRaw?: unknown,
): CreationFlowUserView | undefined { ): CreationFlowUserView | undefined {
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim(); const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!raw) return undefined; if (!raw) return undefined;
@@ -1900,7 +2201,56 @@ function buildCreationFlowUserView(
if (!flow) { if (!flow) {
return { steps: [], parseError: "创作流程无法解析为 JSON需要 steps 数组)" }; return { steps: [], parseError: "创作流程无法解析为 JSON需要 steps 数组)" };
} }
return formatCreationFlowForUser(flow); const accepted = parseAcceptedUnits(
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
return formatCreationFlowForUser(flow, undefined, accepted);
}
function readProposedNextStep(
slots: Record<string, unknown>,
blackboard: Blackboard,
): ProposedNextStepView | undefined {
const raw =
(typeof slots[CREATION_PROPOSED_STEP_TAG] === "string"
? String(slots[CREATION_PROPOSED_STEP_TAG])
: null) || blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
if (!raw?.trim()) return undefined;
try {
const doc = JSON.parse(raw) as Partial<ProposedNextStepView>;
if (typeof doc.stepId !== "string" || typeof doc.name !== "string") {
return undefined;
}
return {
stepId: doc.stepId,
name: doc.name,
declaration:
typeof doc.declaration === "string" ? doc.declaration : undefined,
params:
doc.params && typeof doc.params === "object" && !Array.isArray(doc.params)
? (doc.params as Record<string, unknown>)
: {},
paramsMissing: Array.isArray(doc.paramsMissing)
? doc.paramsMissing.map(String)
: [],
paramSpecs: Array.isArray(doc.paramSpecs)
? doc.paramSpecs
.map((p) => ({
key: String((p as { key?: string }).key ?? ""),
label: String((p as { label?: string }).label ?? ""),
required: Boolean((p as { required?: boolean }).required),
hint:
typeof (p as { hint?: string }).hint === "string"
? (p as { hint: string }).hint
: undefined,
}))
.filter((p) => p.key)
: [],
intent: typeof doc.intent === "string" ? doc.intent : undefined,
};
} catch {
return undefined;
}
} }
function resolveSelectedRecipeView( function resolveSelectedRecipeView(
@@ -1972,19 +2322,46 @@ function buildReviewArtifactView(
for (const tag of art.outputTags) { for (const tag of art.outputTags) {
if (workerSetTags.includes(tag)) continue; if (workerSetTags.includes(tag)) continue;
if (isProgressPointerTag(tag)) continue;
const content = blackboard.getContentByTag(tag); const content = blackboard.getContentByTag(tag);
if (content?.trim()) { if (content?.trim()) {
tagBodies.push(`## ${tag}\n\n${content.trim()}`); tagBodies.push(`## ${tag}\n\n${content.trim()}`);
} }
} }
// 回退消息里也可能夹带「创作.当前步骤」段落,验收正文里去掉
const stripPointerSections = (text: string): string => {
const trimmed = text.trim();
if (!trimmed) return "";
const re = /^##\s+(.+?)\s*$/gm;
const hits: Array<{ title: string; index: number; headerEnd: number }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(trimmed))) {
hits.push({
title: m[1]!.trim(),
index: m.index,
headerEnd: m.index + m[0].length,
});
}
if (!hits.length) return trimmed;
return hits
.filter((h) => !isProgressPointerTag(h.title) && !/当前步骤|当前单位/.test(h.title))
.map((h, i) => {
const end = i + 1 < hits.length ? hits[i + 1]!.index : trimmed.length;
const chunk = trimmed.slice(h.headerEnd, end).trim();
return chunk ? `## ${h.title}\n\n${chunk}` : "";
})
.filter(Boolean)
.join("\n\n");
};
const outputMsg = [...messages] const outputMsg = [...messages]
.reverse() .reverse()
.find((m) => m.kind === "worker_output" && m.actor === art.workerId); .find((m) => m.kind === "worker_output" && m.actor === art.workerId);
const body = const body =
tagBodies.join("\n\n") || tagBodies.join("\n\n") ||
(outputMsg?.body ?? outputMsg?.text ?? "").trim() || stripPointerSections((outputMsg?.body ?? outputMsg?.text ?? "").trim()) ||
art.summary?.trim() || art.summary?.trim() ||
"Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill"; "Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill";
@@ -2028,7 +2405,10 @@ function buildReviewArtifactView(
: undefined; : undefined;
const creationFlowView = isFlowArt const creationFlowView = isFlowArt
? buildCreationFlowUserView(blackboard) ? buildCreationFlowUserView(
blackboard,
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
)
: undefined; : undefined;
return { return {

View File

@@ -7,9 +7,11 @@ import { sessionManager } from "./session-manager.js";
import { handleSettingsApi } from "./settings-handlers.js"; import { handleSettingsApi } from "./settings-handlers.js";
import { handleBooksApi } from "./book-handlers.js"; import { handleBooksApi } from "./book-handlers.js";
import { handleStatsApi } from "./stats-handlers.js"; import { handleStatsApi } from "./stats-handlers.js";
import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../.."); const PROJECT_ROOT = path.resolve(__dirname, "../..");
ensureConsoleUtf8();
loadDotEnv(PROJECT_ROOT); loadDotEnv(PROJECT_ROOT);
const WEB_ROOT = path.resolve(__dirname, "../../web"); const WEB_ROOT = path.resolve(__dirname, "../../web");
const PORT = Number(process.env.PORT ?? 23337); const PORT = Number(process.env.PORT ?? 23337);
@@ -122,12 +124,45 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && sub === "/messages") { if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as { text?: string }; const body = JSON.parse(await readBody(req)) as {
if (!body.text?.trim()) { text?: string;
json(res, 400, { error: "text 不能为空" }); answers?: Array<{
return; questionId?: string;
optionId?: string;
text?: string;
}>;
};
const text = typeof body.text === "string" ? body.text : "";
const trimmed = text.trim();
const answers = (body.answers ?? [])
.filter(
(a) =>
typeof a?.questionId === "string" &&
a.questionId.trim() &&
typeof a?.text === "string" &&
a.text.trim(),
)
.map((a) => ({
questionId: a.questionId!.trim(),
optionId:
typeof a.optionId === "string" && a.optionId.trim()
? a.optionId.trim()
: undefined,
text: a.text!.trim(),
}));
// next_intent 明确可留空;其余态仍要求有字(除非顺带提交了追问)
if (!trimmed && !answers.length) {
const current = sessionManager.get(sessionId);
if (current?.waitingReason?.kind !== "next_intent") {
json(res, 400, { error: "text 不能为空" });
return;
}
} }
const view = await sessionManager.sendMessage(sessionId, body.text.trim()); const view = await sessionManager.sendMessage(
sessionId,
trimmed,
answers.length ? { answers } : undefined,
);
json(res, 200, view); json(res, 200, view);
return; return;
} }
@@ -406,11 +441,19 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && sub === "/actions") { if (req.method === "POST" && sub === "/actions") {
const body = JSON.parse(await readBody(req)) as { action?: string }; const body = JSON.parse(await readBody(req)) as {
action?: string;
stepParams?: Record<string, unknown>;
};
let view; let view;
switch (body.action) { switch (body.action) {
case "approve": case "approve":
view = await sessionManager.approve(sessionId); view = await sessionManager.approve(
sessionId,
body.stepParams && typeof body.stepParams === "object"
? body.stepParams
: undefined,
);
break; break;
case "confirm_intake": case "confirm_intake":
view = await sessionManager.confirmIntake(sessionId); view = await sessionManager.confirmIntake(sessionId);
@@ -452,7 +495,7 @@ const server = createServer(async (req, res) => {
json(res, 404, { error: "Not found" }); json(res, 404, { error: "Not found" });
} }
} catch (err) { } catch (err) {
console.error("[api]", req.method, url.pathname, err); console.error("[接口]", req.method, url.pathname, err);
json(res, 500, { json(res, 500, {
error: err instanceof Error ? err.message : "服务器错误", error: err instanceof Error ? err.message : "服务器错误",
}); });
@@ -460,5 +503,6 @@ const server = createServer(async (req, res) => {
}); });
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Writing Agent 对话页: http://localhost:${PORT}`); console.log(`对话页 http://localhost:${PORT}`);
console.log(`运行日志UTF-8 ${getRuntimeLogPath()}`);
}); });

View File

@@ -115,6 +115,46 @@ export function isContextFragmentDoc(doc: unknown): boolean {
return Boolean(parseContextFragment(doc)); return Boolean(parseContextFragment(doc));
} }
const NON_FRAGMENT_DESIGN_TAGS = new Set([
"设计.创作流程",
"设计.worker集",
"设计.worker集.草稿",
"设计.worker规格",
"设计.上下文投影排序",
]);
/** 该 output tag 应按 context-fragment.v1 验收(有可用「正文」) */
export function expectsContextFragmentTag(tag: string): boolean {
const t = tag.trim();
if (!t.startsWith("设计.")) return false;
return !NON_FRAGMENT_DESIGN_TAGS.has(t);
}
export function hasUsableFragmentBody(body: unknown): boolean {
if (body == null) return false;
if (typeof body === "string") return body.trim().length > 0;
if (Array.isArray(body)) return body.length > 0;
if (typeof body === "object") return Object.keys(body as object).length > 0;
return false;
}
/** 能进验收卡:解析成功且「正文」非空 */
export function isUsableContextFragment(raw: unknown): boolean {
const frag = parseContextFragment(raw);
return Boolean(frag && hasUsableFragmentBody(frag.));
}
export function looksLikeFragmentDoc(doc: unknown): boolean {
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false;
const row = doc as Record<string, unknown>;
if (row.schema === CONTEXT_FRAGMENT_SCHEMA) return true;
if (typeof row. === "string" && row..trim()) return true;
return (
typeof row.brief === "string" &&
("正文" in row || "body" in row)
);
}
/** 投影级别summary 优先 brieffields 尝试列出正文顶层键 */ /** 投影级别summary 优先 brieffields 尝试列出正文顶层键 */
export function projectFragmentContent( export function projectFragmentContent(
raw: string, raw: string,

View File

@@ -7,7 +7,9 @@ import type { BlackboardInputMerge } from "../types/blackboard.js";
import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js"; import { CONTEXT_BRIEF_TAG } from "../runtime/compress-after-worker.js";
import { import {
CREATION_ACCEPTED_CONTENT_TAG, CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
formatAcceptedContentForPrompt, formatAcceptedContentForPrompt,
formatAcceptedUnitsForPrompt,
} from "./creation-units.js"; } from "./creation-units.js";
import { projectFragmentContent } from "./context-fragment.js"; import { projectFragmentContent } from "./context-fragment.js";
import { import {
@@ -107,6 +109,8 @@ function formatSegmentBody(
if (!content) continue; if (!content) continue;
if (tag === CREATION_ACCEPTED_CONTENT_TAG) { if (tag === CREATION_ACCEPTED_CONTENT_TAG) {
content = formatAcceptedContentForPrompt(content); content = formatAcceptedContentForPrompt(content);
} else if (tag === CREATION_ACCEPTED_UNITS_TAG) {
content = formatAcceptedUnitsForPrompt(content);
} }
content = applyPolicy(content, segment.policy); content = applyPolicy(content, segment.policy);
content = applyProjection(content, segment.projection); content = applyProjection(content, segment.projection);
@@ -154,15 +158,20 @@ export function assembleWorkerContext(params: {
const segments = params.segments ?? []; const segments = params.segments ?? [];
if (segments.length === 0) { if (segments.length === 0) {
const revisionNote = (
params.inputs["用户.修订说明"] ??
""
).trim();
return JSON.stringify( return JSON.stringify(
{ {
workerId: params.workerId, workerId: params.workerId,
workerName: params.workerName, workerName: params.workerName,
outputTags: params.outputTags, outputTags: params.outputTags,
inputs: params.inputs, inputs: params.inputs,
instruction: instruction: revisionNote
"根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" + ? "修订模式在已有产物inputs 中对应 outputTags上按「用户.修订说明」以及「用户.worker答复」中的追问作答选项与补充修改保留未点名要改的部分禁止无故整份重写。节点方法task/principles/probe/output仍须遵守。"
"设计.worker集 / 草稿须为 JSON 对象文本。", : "根据 SKILL 说明完成任务。若 inputs 含「上下文.定稿摘要」,以终产物为准。" +
"设计.worker集 / 草稿须为 JSON 对象文本。",
}, },
null, null,
2, 2,
@@ -191,18 +200,61 @@ export function assembleWorkerContext(params: {
blocks.push(`## 上下文.定稿摘要\n\n${brief}`); blocks.push(`## 上下文.定稿摘要\n\n${brief}`);
} }
blocks.push( // 修订态:把本步 outputTags 上已有正文钉成「待改底稿」,禁止无故推倒重写
[ const revisionNote = readTagContent(
"## 本步任务", "用户.修订说明",
"", params.inputs,
`- workerId: \`${params.workerId}\``, params.blackboard,
`- workerName: ${params.workerName}`, inputMerge,
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`, ).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"),
);
}
}
const taskLines = [
"## 本步任务",
"",
`- workerId: \`${params.workerId}\``,
`- workerName: ${params.workerName}`,
`- outputTags: ${params.outputTags.map((t) => `\`${t}\``).join("、") || "(无)"}`,
"",
];
if (revisionNote) {
taskLines.push(
"**修订模式**:接着上方【待改底稿】(若有)与依赖产物,落实「用户.修订说明」以及「用户.worker答复」里的追问作答选项与补充意见。",
"不要从零另起一份;不要扩大修改面。标为「只读 / 已定稿」的分区仍不可改。节点方法仍有效。",
);
} else {
taskLines.push(
"按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。", "按 SKILL 与上方分区完成任务。标为「只读 / 已定稿」的分区不要擅自改写;只改【本单位】范围。",
"设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。", );
].join("\n"), }
); taskLines.push("设计.worker集 / 草稿须为 JSON 对象文本(以 `{` 开头)。");
blocks.push(taskLines.join("\n"));
return blocks.join("\n\n---\n\n"); return blocks.join("\n\n---\n\n");
} }

View File

@@ -13,8 +13,36 @@ import { parse as parseYaml } from "yaml";
export const CREATION_FLOW_TAG = "设计.创作流程"; export const CREATION_FLOW_TAG = "设计.创作流程";
export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤"; export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤";
/** 确认开干前待确认的下一步快照JSON */
export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤";
/** 用户手动选定的初始配方(存 recipe id或 JSON {id,name} */ /** 用户手动选定的初始配方(存 recipe id或 JSON {id,name} */
export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方"; export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方";
/** 进度指针:禁止当作 worker 产物写回或列入验收正文 */
export const PROGRESS_POINTER_TAGS = new Set<string>([
CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG,
"创作.当前单位",
"创作.已验收单位",
"创作.已验收内容",
"创作.能力开场白",
"创作.能力开场状态",
]);
/** 旧名:含「设计.创作流程」。写回/验收过滤请用 isProgressPointerTag */
export const RUNTIME_PINNED_TAGS = new Set<string>([
CREATION_FLOW_TAG,
...PROGRESS_POINTER_TAGS,
]);
export function isProgressPointerTag(tag: string): boolean {
return PROGRESS_POINTER_TAGS.has(tag.trim());
}
export function isRuntimePinnedTag(tag: string): boolean {
return RUNTIME_PINNED_TAGS.has(tag.trim());
}
export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml"; export const MODULE_CATALOG_FILENAME = "modules/catalog.yaml";
export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml"; export const RECIPE_CATALOG_FILENAME = "recipes/catalog.yaml";
export const DESIGN_STEP_WORKER_ID = "design-step"; export const DESIGN_STEP_WORKER_ID = "design-step";
@@ -152,6 +180,9 @@ export type CreationFlowValidation = {
errors: string[]; errors: string[];
}; };
/** 工作流计划节点相对执行进度(给人看的三态) */
export type FlowStepRunState = "done" | "current" | "pending";
export type CreationFlowUserView = { export type CreationFlowUserView = {
brief?: string; brief?: string;
status?: CreationFlowStatus; status?: CreationFlowStatus;
@@ -169,6 +200,8 @@ export type CreationFlowUserView = {
params?: CreationFlowStepParams; params?: CreationFlowStepParams;
/** 参数缺必填项时的提示(给人看) */ /** 参数缺必填项时的提示(给人看) */
paramsMissing?: string[]; paramsMissing?: string[];
/** 已执行 / 将要执行 / 未执行 */
runState: FlowStepRunState;
}>; }>;
parseError?: string; parseError?: string;
}; };
@@ -301,6 +334,42 @@ export function stepUnitId(step: CreationFlowStep): string {
return step.id || step.name; return step.id || step.name;
} }
/**
* 验收时记下哪一步做完。只认流程里的真实 step.id
* LLM 可能把产物误写进「创作.当前步骤」,那种内容不能当进度。
*/
export function pickRecordedStepId(params: {
flow: CreationFlow | null;
alreadyAccepted?: readonly string[];
pinnedUnitId?: string | null;
writtenCurrentStep?: string | null;
summaryHint?: string | null;
}): string | null {
const flow = params.flow;
const tryRef = (ref: string | undefined | null): string | null => {
const key = ref?.trim();
if (!key || key === "flow") return null;
if (!flow) return key;
const step = findStepByRef(flow, key);
return step ? stepUnitId(step) : null;
};
const pinned = tryRef(params.pinnedUnitId);
if (pinned) return pinned;
const written = tryRef(params.writtenCurrentStep);
if (written) return written;
const hinted = tryRef(params.summaryHint);
if (hinted) return hinted;
if (flow) {
const pending = nextPendingStep(flow, params.alreadyAccepted ?? []);
if (pending) return stepUnitId(pending);
}
return null;
}
export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null { export function parseCreationFlow(raw: string | undefined | null): CreationFlow | null {
if (!raw?.trim()) return null; if (!raw?.trim()) return null;
const doc = extractJsonObject(raw); const doc = extractJsonObject(raw);
@@ -591,6 +660,93 @@ export function stringifyModuleOpeningState(state: ModuleOpeningState): string {
return JSON.stringify(state); return JSON.stringify(state);
} }
/**
* 序列化「设计.创作流程」(程序 seed / 验收写回共用)。
*/
export function stringifyCreationFlow(flow: CreationFlow): string {
return JSON.stringify({
version: 1,
...(flow.brief ? { brief: flow.brief } : {}),
...(flow.status ? { status: flow.status } : {}),
steps: flow.steps,
});
}
/**
* 编排层重排流程时,已验收步骤按原样保留:
* 缺了就按原位置补回,改了名字/参数就还原。编排只能动未验收步。
*/
export function mergeCreationFlowPreservingAccepted(params: {
prevRaw: string | null | undefined;
nextRaw: string;
acceptedStepIds: readonly string[];
}): { raw: string; restored: string[] } {
const next = parseCreationFlow(params.nextRaw);
const prev = parseCreationFlow(params.prevRaw);
if (!next || !prev) return { raw: params.nextRaw, restored: [] };
const accepted = prev.steps.filter((s) => isStepAccepted(s, params.acceptedStepIds));
if (accepted.length === 0) return { raw: params.nextRaw, restored: [] };
const restored: string[] = [];
const steps = [...next.steps];
for (let i = 0; i < accepted.length; i++) {
const frozen = accepted[i]!;
const at = steps.findIndex((s) => s.id === frozen.id);
if (at < 0) {
steps.splice(Math.min(i, steps.length), 0, frozen);
restored.push(frozen.id);
continue;
}
const current = steps[at]!;
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);
if (changed) {
steps[at] = frozen;
restored.push(frozen.id);
}
}
if (restored.length === 0) return { raw: params.nextRaw, restored: [] };
return {
raw: stringifyCreationFlow({ ...next, version: 1, steps }),
restored,
};
}
/**
* 配方近期起点 → 可写入黑板的开局 DAG。
* steps 为空则返回 null仍只当选型参考不预置流程
*/
export function creationFlowFromRecipeSeed(
detail: Pick<RecipeDetail, "seed">,
): CreationFlow | null {
const seed = detail.seed;
if (!seed?.steps?.length) return null;
return {
version: 1,
...(seed.brief ? { brief: seed.brief } : {}),
status: seed.status ?? "open",
steps: ensureCreationFlowStepIds(seed.steps),
};
}
/**
* 已坐在配方开局步(无 depends_on且「用户.需求」有首句时:
* 跳过程序再抛模块 opening——首句即该步对话不是「先开场再进 DAG」。
*/
export function shouldSkipModuleOpening(opts: {
demand: string;
dependsOn?: string[] | null;
phase?: "shown" | "answered" | null;
}): boolean {
if (opts.phase === "shown" || opts.phase === "answered") return false;
if (!String(opts.demand ?? "").trim()) return false;
const deps = opts.dependsOn ?? [];
return deps.length === 0;
}
/** 无 id 时的兜底(目录仍应显式写 id */ /** 无 id 时的兜底(目录仍应显式写 id */
function slugFromName(name: string): string { function slugFromName(name: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -661,6 +817,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
"【能力 · 可选工序】", "【能力 · 可选工序】",
"按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。", "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入。",
"选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta有「编排参数」的步骤必须在 DAG 里写齐 params缺参时用 askUser 选项+其它,禁止空壳进执行。", "选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta有「编排参数」的步骤必须在 DAG 里写齐 params缺参时用 askUser 选项+其它,禁止空壳进执行。",
"生成规则:改 target 时必须同步改 rule_id英文 kebab-case与步骤 id生成规则·{对象});禁止只改中文对象沿用旧后台 id。",
"不要把能力执行全文塞进本步;执行由 design-step 注入。", "不要把能力执行全文塞进本步;执行由 design-step 注入。",
lines.join("\n"), lines.join("\n"),
].join("\n"); ].join("\n");
@@ -929,13 +1086,23 @@ function formatRecipeFieldBlock(value: string | string[]): string {
return value; return value;
} }
/** design-flow 一次注入:已选配方 + 技能池 */ /** design-flow 一次注入:流程进度 + 已选配方 + 技能池 */
export function formatDesignFlowContentBlocks(params: { export function formatDesignFlowContentBlocks(params: {
selectedRecipe?: RecipeDetail | null; selectedRecipe?: RecipeDetail | null;
modules?: ModuleCatalog | null; modules?: ModuleCatalog | null;
missingSelection?: boolean; missingSelection?: boolean;
flow?: CreationFlow | null;
acceptedStepIds?: readonly string[];
filledArtifactTags?: readonly string[];
}): string[] { }): string[] {
const blocks: string[] = []; const blocks: string[] = [
formatFlowProgressForAgent({
flow: params.flow,
acceptedStepIds: params.acceptedStepIds,
catalog: params.modules,
filledArtifactTags: params.filledArtifactTags,
}),
];
if (params.missingSelection) { if (params.missingSelection) {
blocks.push( blocks.push(
[ [
@@ -953,6 +1120,120 @@ export function formatDesignFlowContentBlocks(params: {
return blocks; return blocks;
} }
/**
* 编排器必须看见的进度:哪些步已做完、草案里已有什么、哪些能力才允许再编入。
* 裸 JSON id 列表不够;此块由程序钉死。
*/
export function formatFlowProgressForAgent(params: {
flow?: CreationFlow | null;
acceptedStepIds?: readonly string[];
catalog?: ModuleCatalog | null;
filledArtifactTags?: readonly string[];
}): string {
const accepted = (params.acceptedStepIds ?? []).filter(
(id) => Boolean(id?.trim()) && id.trim() !== "flow",
);
const flow = params.flow ?? null;
const catalog = params.catalog ?? null;
const filled = new Set(params.filledArtifactTags ?? []);
const repeatableNames = new Set(
(catalog?.modules ?? [])
.filter((m) => m.repeatable === true)
.map((m) => m.name),
);
const doneLines: string[] = [];
const doneNames = new Set<string>();
const doneIds = new Set<string>();
const markDone = (
name: string,
id: string | undefined,
why: string,
repeatable: boolean,
) => {
const flag = repeatable
? "〔可反复:可再追加不同 id"
: "〔不可反复:禁止再排一次执行〕";
const idBit = id ? `id: ${id}` : "";
doneLines.push(`- ${name}${idBit} · ${why} ${flag}`);
doneNames.add(name);
if (id) doneIds.add(id);
};
if (flow) {
for (const step of flow.steps) {
const acceptedHere = isStepAccepted(step, accepted);
const art = catalog ? artifactTagForStep(step.name, catalog) : null;
const hasArtifact = Boolean(art && filled.has(art));
const repeatable = repeatableNames.has(step.name);
if (acceptedHere || (!repeatable && hasArtifact)) {
markDone(
step.name,
step.id,
acceptedHere ? "已验收" : "产物已在黑板",
repeatable,
);
}
}
}
for (const id of accepted) {
if (doneIds.has(id)) continue;
if (flow?.steps.some((s) => isStepAccepted(s, [id]))) continue;
markDone(id, id, "已验收", repeatableNames.has(id));
}
if (catalog) {
for (const mod of catalog.modules) {
if (mod.repeatable === true) continue;
if (!mod.artifact || !filled.has(mod.artifact)) continue;
if (doneNames.has(mod.name)) continue;
markDone(mod.name, undefined, "产物已在黑板", false);
}
}
const draftLines: string[] = [];
if (flow) {
for (const step of flow.steps) {
if (doneIds.has(step.id) || doneNames.has(step.name)) continue;
draftLines.push(
`- ${step.name}id: ${step.id})· 已在草案,保留原 id不要当作新规划再写一遍`,
);
}
}
const repeatableList = (catalog?.modules ?? [])
.filter((m) => m.repeatable === true)
.map((m) => m.name);
const lines = [
"【流程进度】(程序钉死,必须遵守)",
"你的任务是追加缺口,不是从头规划。",
"已完成的非反复技能:只保留原 id 作 depends_on 锚点,禁止再作为将要执行的一步。",
"草案里已有的步骤:原样保留,不要重排一遍开局。",
"只有能力目录标了〔可反复〕的(如生成规则、具体实例)才允许再编入新 id。",
];
if (doneLines.length) {
lines.push("", "已完成:", ...doneLines);
} else {
lines.push(
"",
"已完成:尚无已验收步骤。若草案已有开局步,那是配方预置起点,不是你新规划的。",
);
}
if (draftLines.length) {
lines.push("", "草案已有、尚未验收(保留,勿重排):", ...draftLines);
}
lines.push(
"",
repeatableList.length
? `可反复追加:${repeatableList.join("、")}`
: "可反复追加:以能力目录〔可反复〕为准。",
);
return lines.join("\n");
}
/** 解析并加载用户已选配方详情 */ /** 解析并加载用户已选配方详情 */
export async function resolveSelectedRecipeDetail(params: { export async function resolveSelectedRecipeDetail(params: {
skillPackRoot: string; skillPackRoot: string;
@@ -973,7 +1254,6 @@ export function validateCreationFlow(
catalog: ModuleCatalog | null, catalog: ModuleCatalog | null,
): CreationFlowValidation { ): CreationFlowValidation {
const errors: string[] = []; const errors: string[] = [];
const ids = flow.steps.map((s) => s.id);
const seenIds = new Set<string>(); const seenIds = new Set<string>();
const allowed = catalog const allowed = catalog
? new Set(catalog.modules.map((m) => m.name)) ? new Set(catalog.modules.map((m) => m.name))
@@ -1020,25 +1300,53 @@ export function validateCreationFlow(
} }
} }
if (mod?.params?.length) { // 必填 params 改在「确认开干」时钉齐,不再阻挡编排产出空壳步骤
const missing = missingRequiredStepParams(step, mod);
if (missing.length > 0) {
const labels = missing
.map((key) => {
const spec = mod.params!.find((p) => p.key === key);
return spec ? `${key}${spec.label}` : key;
})
.join("、");
errors.push(
`${step.id}」缺少必填编排参数:${labels}(须在 design-flow 钉齐后再执行)`,
);
}
}
} }
return { ok: errors.length === 0, errors }; return { ok: errors.length === 0, errors };
} }
/** 进 design-step 前:本步必填编排参数是否已齐 */
export function validateStepReadyToRun(
step: CreationFlowStep,
catalog: ModuleCatalog | null,
): CreationFlowValidation {
const mod = catalog?.modules.find((m) => m.name === step.name);
if (!mod?.params?.length) return { ok: true, errors: [] };
const missing = missingRequiredStepParams(step, mod);
if (missing.length === 0) return { ok: true, errors: [] };
const labels = missing
.map((key) => {
const spec = mod.params!.find((p) => p.key === key);
return spec ? `${key}${spec.label}` : key;
})
.join("、");
return {
ok: false,
errors: [`${step.id}」缺少必填编排参数:${labels}(确认开干前请补齐)`],
};
}
/** 写回某步 params确认下一节点时由用户钉死 */
export function patchCreationFlowStepParams(
flow: CreationFlow,
stepId: string,
params: CreationFlowStepParams,
): CreationFlow {
const id = stepId.trim();
return {
...flow,
steps: flow.steps.map((s) =>
s.id === id
? {
...s,
params: Object.keys(params).length ? { ...params } : undefined,
}
: s,
),
};
}
export function artifactTagForStep( export function artifactTagForStep(
name: string, name: string,
catalog: ModuleCatalog | null, catalog: ModuleCatalog | null,
@@ -1049,11 +1357,13 @@ export function artifactTagForStep(
export function formatCreationFlowForUser( export function formatCreationFlowForUser(
flow: CreationFlow, flow: CreationFlow,
catalog?: ModuleCatalog | null, catalog?: ModuleCatalog | null,
acceptedStepIds: readonly string[] = [],
): CreationFlowUserView { ): CreationFlowUserView {
const decl = new Map( const decl = new Map(
(catalog?.modules ?? []).map((m) => [m.name, m] as const), (catalog?.modules ?? []).map((m) => [m.name, m] as const),
); );
const seenName = new Map<string, number>(); const seenName = new Map<string, number>();
const next = nextPendingStep(flow, acceptedStepIds);
return { return {
brief: flow.brief, brief: flow.brief,
status: flow.status, status: flow.status,
@@ -1062,6 +1372,11 @@ export function formatCreationFlowForUser(
seenName.set(s.name, n); seenName.set(s.name, n);
const mod = decl.get(s.name); const mod = decl.get(s.name);
const paramsMissing = missingRequiredStepParams(s, mod); const paramsMissing = missingRequiredStepParams(s, mod);
const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds)
? "done"
: next && next.id === s.id
? "current"
: "pending";
return { return {
order: i + 1, order: i + 1,
id: s.id, id: s.id,
@@ -1070,6 +1385,7 @@ export function formatCreationFlowForUser(
occurrence: n, occurrence: n,
declaration: mod?.declaration, declaration: mod?.declaration,
repeatable: mod?.repeatable, repeatable: mod?.repeatable,
runState,
...(s.params ? { params: s.params } : {}), ...(s.params ? { params: s.params } : {}),
...(paramsMissing.length > 0 ? { paramsMissing } : {}), ...(paramsMissing.length > 0 ? { paramsMissing } : {}),
}; };

View File

@@ -491,6 +491,13 @@ export function parseAcceptedUnits(raw: unknown): string[] {
return []; return [];
} }
/** 给人 / 编排 LLM 看的已验收步骤清单去掉「flow」那是计划本身不是某一步。 */
export function formatAcceptedUnitsForPrompt(raw: unknown): string {
const ids = parseAcceptedUnits(raw).filter((id) => id && id !== "flow");
if (ids.length === 0) return "(尚无已验收步骤)";
return ids.map((id) => `- ${id}`).join("\n");
}
export function isFinalWorkerSetArtifact(artifact: { export function isFinalWorkerSetArtifact(artifact: {
workerId: string; workerId: string;
outputTags: string[]; outputTags: string[];

View File

@@ -101,6 +101,7 @@ export async function resolveRunnableWorker(params: {
currentStepName, currentStepName,
acceptedStepNames: parseAcceptedSteps(acceptedRaw), acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef, selectedRecipeRef,
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
}, },
); );
return { return {

View File

@@ -473,6 +473,8 @@ export async function loadWorkerSkillWithContext(
acceptedStepNames?: readonly string[]; acceptedStepNames?: readonly string[];
/** 用户手动选定的配方 id / 名 */ /** 用户手动选定的配方 id / 名 */
selectedRecipeRef?: string | null; selectedRecipeRef?: string | null;
/** 黑板已有产物 tag供编排器判断哪些技能已经做过 */
filledArtifactTags?: readonly string[];
}, },
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> { ): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot); const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
@@ -488,6 +490,7 @@ export async function loadWorkerSkillWithContext(
loadModuleCatalog, loadModuleCatalog,
resolveSelectedRecipeDetail, resolveSelectedRecipeDetail,
formatDesignFlowContentBlocks, formatDesignFlowContentBlocks,
parseCreationFlow,
} = await import("./creation-flow.js"); } = await import("./creation-flow.js");
const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot); const modules = await loadModuleCatalog(skill.skillPackRoot, skillsRoot);
const selectedRecipe = await resolveSelectedRecipeDetail({ const selectedRecipe = await resolveSelectedRecipeDetail({
@@ -499,6 +502,9 @@ export async function loadWorkerSkillWithContext(
selectedRecipe, selectedRecipe,
modules, modules,
missingSelection: !selectedRecipe, missingSelection: !selectedRecipe,
flow: parseCreationFlow(opts?.flowRaw),
acceptedStepIds: opts?.acceptedStepNames,
filledArtifactTags: opts?.filledArtifactTags,
}); });
if (blocks.length) { if (blocks.length) {
moduleCatalogBlock = blocks.join("\n\n"); moduleCatalogBlock = blocks.join("\n\n");
@@ -537,10 +543,7 @@ export async function loadWorkerSkillWithContext(
...binding.depTags, ...binding.depTags,
]; ];
const inputTags = [...new Set(baseInputs)]; const inputTags = [...new Set(baseInputs)];
const outputTags = [ const outputTags = [binding.module.artifact];
binding.module.artifact,
CREATION_CURRENT_STEP_TAG,
];
const depSegments = binding.depTags.map((tag, i) => ({ const depSegments = binding.depTags.map((tag, i) => ({
id: `dep-${i}`, id: `dep-${i}`,
tier: "static" as const, tier: "static" as const,

View File

@@ -10,6 +10,17 @@ import type {
export type { QuestionAnswer, QuestionItem, QuestionOption }; export type { QuestionAnswer, QuestionItem, QuestionOption };
/** 能力默认问题opening与追问卡分流对齐美学纲领开局引导 */
export const MODULE_OPENING_QUESTION_ID = "module-opening";
/** 是否仅为能力默认问题(应走说话面 + openingGuide不进答题卡 */
export function isModuleOpeningQuestions(
questions: readonly QuestionItem[] | undefined | null,
): boolean {
if (!questions?.length || questions.length !== 1) return false;
return questions[0]?.id === MODULE_OPENING_QUESTION_ID;
}
function letterId(i: number): string { function letterId(i: number): string {
return String.fromCharCode(65 + (i % 26)); return String.fromCharCode(65 + (i % 26));
} }
@@ -88,6 +99,138 @@ export function normalizeQuestions(raw: unknown): QuestionItem[] {
return out; return out;
} }
const EXAMPLE_SUFFIX = /(?:\n|^)\s*示例[:][\s\S]*$/;
/** 去掉「示例:」后比题干,避免同一问因示例行对不上 */
export function questionPromptStem(prompt: string): string {
return prompt.replace(EXAMPLE_SUFFIX, "").replace(/\s+/g, "").trim();
}
function optionSignature(q: QuestionItem): string {
return (q.options ?? [])
.map((o) => o.label.trim())
.filter((label) => label && !/^其它/.test(label))
.join("\n");
}
export function isSameFollowUpQuestion(a: QuestionItem, b: QuestionItem): boolean {
const sigA = optionSignature(a);
const sigB = optionSignature(b);
if (sigA && sigA === sigB) return true;
const sa = questionPromptStem(a.prompt);
const sb = questionPromptStem(b.prompt);
if (!sa || !sb) return false;
if (sa === sb) return true;
return sa.includes(sb) || sb.includes(sa);
}
export const SLOT_ASKED_QUESTIONS = "askedFollowUps";
/** 已问过的题目留痕(写进 slots 供跨 worker 去重) */
export type AskedQuestionRecord = {
prompt: string;
options?: string[];
};
const ASKED_HISTORY_LIMIT = 40;
export function toAskedQuestionRecord(q: QuestionItem): AskedQuestionRecord {
const options = (q.options ?? []).map((o) => o.label.trim()).filter(Boolean);
return options.length ? { prompt: q.prompt, options } : { prompt: q.prompt };
}
export function parseAskedQuestions(raw: unknown): AskedQuestionRecord[] {
const list = Array.isArray(raw)
? raw
: typeof raw === "string" && raw.trim()
? (() => {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
})()
: null;
if (!Array.isArray(list)) return [];
const out: AskedQuestionRecord[] = [];
for (const item of list) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
const prompt = typeof row.prompt === "string" ? row.prompt.trim() : "";
if (!prompt) continue;
const options = Array.isArray(row.options)
? row.options.map((o) => String(o).trim()).filter(Boolean)
: undefined;
out.push(options?.length ? { prompt, options } : { prompt });
}
return out;
}
function recordAsItem(rec: AskedQuestionRecord): QuestionItem {
return {
id: "asked",
prompt: rec.prompt,
options: rec.options?.map((label, i) => ({ id: letterId(i), label })),
};
}
export function wasAlreadyAsked(
q: QuestionItem,
history: readonly AskedQuestionRecord[],
): boolean {
return history.some((rec) => isSameFollowUpQuestion(q, recordAsItem(rec)));
}
/** 过滤掉本局已经问过的题(用于可跳过的挂载追问) */
export function dropAlreadyAskedQuestions(
questions: readonly QuestionItem[],
history: readonly AskedQuestionRecord[],
): QuestionItem[] {
if (!history.length) return [...questions];
return questions.filter((q) => !wasAlreadyAsked(q, history));
}
export function appendAskedQuestions(
history: readonly AskedQuestionRecord[],
questions: readonly QuestionItem[],
): AskedQuestionRecord[] {
const next = [...history];
for (const q of questions) {
if (wasAlreadyAsked(q, next)) continue;
next.push(toAskedQuestionRecord(q));
}
return next.slice(-ASKED_HISTORY_LIMIT);
}
/**
* askUser 与片段「追问」合并:同一问只留一份,优先带示例的片段题。
* 仅片段没有的缺口才保留 askUser。
*/
export function mergeQuestionsPreferFragment(
askUser: QuestionItem[] | undefined,
fragment: QuestionItem[],
): QuestionItem[] {
if (!fragment.length) return askUser?.length ? [...askUser] : [];
if (!askUser?.length) return [...fragment];
const usedAsk = new Set<number>();
const out: QuestionItem[] = [];
for (const fq of fragment) {
const matchIdx = askUser.findIndex(
(aq, i) => !usedAsk.has(i) && isSameFollowUpQuestion(aq, fq),
);
if (matchIdx >= 0) usedAsk.add(matchIdx);
if (!out.some((q) => isSameFollowUpQuestion(q, fq))) out.push(fq);
}
for (let i = 0; i < askUser.length; i++) {
if (usedAsk.has(i)) continue;
const aq = askUser[i]!;
if (out.some((q) => isSameFollowUpQuestion(q, aq))) continue;
out.push(aq);
}
return out;
}
/** 发给 AI必须含问题与答案可选自由补充 */ /** 发给 AI必须含问题与答案可选自由补充 */
export function formatQuestionAnswersForAi( export function formatQuestionAnswersForAi(
questions: QuestionItem[], questions: QuestionItem[],

View File

@@ -51,7 +51,13 @@ export type WaitingReason =
questions: QuestionItem[]; questions: QuestionItem[];
pageSize?: number; pageSize?: number;
} // worker 无产物时的阻塞提问 } // worker 无产物时的阻塞提问
| { kind: "revision"; instruction?: string }; // 产物被拒或程序验收失败 | { kind: "revision"; instruction?: string } // 无意见打回时等补交;有意见则直接重跑
| {
/** 上一步已验收:先问「下一步想写什么」(可留空),再提案并确认开干 */
kind: "next_intent";
afterWorkerId?: string;
afterUnitId?: string;
};
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */ /** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
export type SkillIndexEntry = { export type SkillIndexEntry = {
@@ -249,6 +255,11 @@ export type RuntimeEvent =
questions: QuestionItem[] | string[]; questions: QuestionItem[] | string[];
}; };
} }
| {
/** 按意见重跑没写出新产物:退回上一版产物验收,重试追问改挂为可选 */
type: "worker_revision_produced_nothing";
payload: { artifactId: string; questions?: QuestionItem[] | string[] };
}
| { | {
/** 验收态下作答/跳过挂载追问:不离开 review_artifact */ /** 验收态下作答/跳过挂载追问:不离开 review_artifact */
type: "user_resolved_sidecar_questions"; type: "user_resolved_sidecar_questions";
@@ -316,7 +327,9 @@ export type PhaseEffect =
} }
| { type: "resume_worker" } | { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string } | { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string }; | { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
| { type: "propose_next_creation_step" };
/** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */ /** applyEvent 的返回值:新会话快照 + 待处理副作用 + 可选错误 */
export type ApplyEventResult = { export type ApplyEventResult = {

View File

@@ -15,10 +15,17 @@ import {
} from "../skills/worker-set-parse.js"; } from "../skills/worker-set-parse.js";
import { assembleWorkerContext } from "../skills/context-segments.js"; import { assembleWorkerContext } from "../skills/context-segments.js";
import { import {
mergeQuestionsPreferFragment,
normalizeQuestions, normalizeQuestions,
type QuestionItem, type QuestionItem,
} from "../skills/question-protocol.js"; } from "../skills/question-protocol.js";
import { extractFragmentAskSidecar } from "../skills/context-fragment.js"; import {
extractFragmentAskSidecar,
expectsContextFragmentTag,
isUsableContextFragment,
looksLikeFragmentDoc,
} from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js";
export type WorkerRunParams = { export type WorkerRunParams = {
skillName: string; skillName: string;
@@ -66,7 +73,8 @@ askUser 每项可为:
- outputs 的 key 必须是要求的 outputTags - outputs 的 key 必须是要求的 outputTags
- **设计.worker集 / 设计.worker集.草稿**value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML - **设计.worker集 / 设计.worker集.草稿**value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **优先同时给 outputs + askUser**:先交出可用草稿/产物,追问挂在产物下(用户可直接接受而不作答)。仅当完全无法产出时才留空 outputs、只填 askUser - **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
- **其它产物**:优先同时给 outputs + askUser有产物时追问挂在产物下用户可直接接受而不作答。仅当完全无法产出时才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options完整句、可改写不要只丢裸问题逼用户写长段 - 能推断选项时 **必须**给 options完整句、可改写不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief已有实质内容禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问 - 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief已有实质内容禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`; - summary 用于界面展示`;
@@ -127,6 +135,102 @@ function gatherInputs(
return inputs; return inputs;
} }
function stringifyOutputValue(val: unknown): string | undefined {
if (typeof val === "string" && val.trim()) return val.trim();
if (val && typeof val === "object") return JSON.stringify(val);
return undefined;
}
function productOutputTags(outputTags: string[]): string[] {
return outputTags.filter((t) => t.trim() && !isProgressPointerTag(t));
}
function assignOutput(
outputs: Record<string, string>,
tag: string | undefined,
val: unknown,
): void {
if (!tag || isProgressPointerTag(tag)) return;
const text = stringifyOutputValue(val);
if (text) outputs[tag] = text;
}
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
function recoverFragmentOutputs(
obj: Record<string, unknown>,
outputs: Record<string, string>,
outputTags: string[],
): void {
const target = productOutputTags(outputTags)[0];
if (!target) return;
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
const outputsRaw =
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
? (obj.outputs as Record<string, unknown>)
: null;
if (outputsRaw) {
for (const [key, val] of Object.entries(outputsRaw)) {
if (key === target) continue;
const text = stringifyOutputValue(val);
if (!text) continue;
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
outputs[target] = text;
return;
}
}
}
const protocolKeys = new Set(["outputs", "summary", "askUser", "askAssessment"]);
const rest: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (!protocolKeys.has(k)) rest[k] = v;
}
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
const source = looksLikeFragmentDoc(obj) ? obj : rest;
outputs[target] = JSON.stringify(source);
}
}
function dropUnusableFragmentOutputs(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)) {
delete outputs[tag];
}
}
}
function tryParseObject(text: string): unknown {
const extracted = extractJsonObjectText(text) ?? text.trim();
try {
return JSON.parse(extracted);
} catch {
return undefined;
}
}
const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
{
id: "design-step-retry",
prompt:
"这一步还没写出可验收的产物。请再补一点你最在意的体验或参与方式;也可以说「按已有描述先出一版」。",
allowOther: true,
required: true,
},
]);
function parseWorkerResponse( function parseWorkerResponse(
raw: string, raw: string,
outputTags: string[], outputTags: string[],
@@ -135,7 +239,6 @@ function parseWorkerResponse(
try { try {
parsed = JSON.parse(raw); parsed = JSON.parse(raw);
} catch { } catch {
// 整段不是协议 JSON切勿把散文塞进设计.worker集*
if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) { if (outputTags.some((t) => WORKER_SET_OUTPUT_TAGS.has(t))) {
const questions = normalizeQuestions(extractQuestionsFromText(raw)); const questions = normalizeQuestions(extractQuestionsFromText(raw));
return { return {
@@ -149,16 +252,20 @@ function parseWorkerResponse(
]), ]),
}; };
} }
const fallback: Record<string, string> = {}; const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (outputTags.length === 1) { if (target && isUsableContextFragment(raw)) {
fallback[outputTags[0]] = raw; return finalizeParsedOutputs(
} else { { [target]: raw },
fallback[outputTags[0] ?? "output.草稿"] = raw; { summary: raw.slice(0, 80) },
outputTags,
);
} }
const questions = normalizeQuestions(extractQuestionsFromText(raw));
return { return {
outputs: fallback, outputs: {},
summary: raw.slice(0, 80), summary: "未产出可验收产物",
preview: raw.slice(0, 600), preview: raw.slice(0, 600),
askUser: questions.length ? questions.slice(0, 2) : INCOMPLETE_FRAGMENT_ASK,
}; };
} }
@@ -171,17 +278,20 @@ function parseWorkerResponse(
const outputs: Record<string, string> = {}; const outputs: Record<string, string> = {};
if (outputsRaw && typeof outputsRaw === "object") { if (outputsRaw && typeof outputsRaw === "object") {
for (const tag of outputTags) { for (const tag of productOutputTags(outputTags)) {
const val = (outputsRaw as Record<string, unknown>)[tag]; assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
if (typeof val === "string" && val.trim()) {
outputs[tag] = val.trim();
} else if (val && typeof val === "object") {
// 模型有时直接回对象而非字符串
outputs[tag] = JSON.stringify(val);
}
} }
} }
recoverFragmentOutputs(obj, outputs, outputTags);
return finalizeParsedOutputs(outputs, obj, outputTags);
}
function finalizeParsedOutputs(
outputs: Record<string, string>,
obj: Record<string, unknown>,
_outputTags: string[],
): WorkerRunResult {
const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim()); const isMetaAskToken = (s: string) => /^ask[_-]?user$/i.test(s.trim());
let askUser: QuestionItem[] | undefined; let askUser: QuestionItem[] | undefined;
@@ -213,7 +323,7 @@ function parseWorkerResponse(
} }
} }
// context-fragment.v1正文内 追问/自评 → 挂到询问卡(不必再抄一份 askUser // 先抽追问,再丢掉半残片段——无正文时仍要把题留给 LLM loop
let askAssessment: string | undefined; let askAssessment: string | undefined;
const fragQuestions: QuestionItem[] = []; const fragQuestions: QuestionItem[] = [];
for (const content of Object.values(outputs)) { for (const content of Object.values(outputs)) {
@@ -226,19 +336,20 @@ function parseWorkerResponse(
} }
} }
if (fragQuestions.length) { if (fragQuestions.length) {
if (!askUser?.length) { askUser = mergeQuestionsPreferFragment(askUser, fragQuestions);
askUser = fragQuestions; }
} else {
for (const q of fragQuestions) { dropUnusableFragmentOutputs(outputs);
if (!askUser.some((x) => x.prompt === q.prompt)) askUser.push(q);
} if (Object.keys(outputs).length === 0 && (!askUser || askUser.length === 0)) {
} askUser = normalizeQuestions(INCOMPLETE_FRAGMENT_ASK);
} }
const summary = const summary =
typeof obj.summary === "string" && obj.summary.trim() typeof obj.summary === "string" && obj.summary.trim()
? obj.summary.trim() ? obj.summary.trim()
: Object.values(outputs)[0]?.slice(0, 80) ?? "Worker 已完成"; : Object.values(outputs)[0]?.slice(0, 80) ??
(askUser?.length ? `待补充:${askUser[0]!.prompt.slice(0, 40)}` : "Worker 已完成");
const preview = const preview =
Object.entries(outputs) Object.entries(outputs)
@@ -379,6 +490,9 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
} catch { } catch {
acceptedStepNames = []; acceptedStepNames = [];
} }
const filledArtifactTags = params.blackboard
.listTagIndex()
.map((item) => item.tag);
const loaded = await loadWorkerSkillWithContext( const loaded = await loadWorkerSkillWithContext(
params.skillName, params.skillName,
workerId, workerId,
@@ -388,6 +502,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
currentStepName, currentStepName,
acceptedStepNames, acceptedStepNames,
selectedRecipeRef, selectedRecipeRef,
filledArtifactTags,
}, },
); );
worker = loaded.worker; worker = loaded.worker;

View File

@@ -46,8 +46,9 @@ if exist ".env" (
) )
echo [信息] 启动桌面窗口 http://localhost:%PORT% echo [信息] 启动桌面窗口 http://localhost:%PORT%
echo [信息] 调试: F12 或 Ctrl+Shift+I 打开开发者工具 echo [信息] 后端日志在 %USERPROFILE%\.writing-agent\logs\runtime.log
echo [信息] 自动打开 DevTools: set WRITING_AGENT_ELECTRON_DEV=1 ^&^& npm run electron echo [信息] 显示控制台请用 start-app.bat console
echo [信息] F12 或 Ctrl+Shift+I 打开开发者工具,只有前端
echo [信息] 关闭窗口即停止服务 echo [信息] 关闭窗口即停止服务
echo. echo.

View File

@@ -41,13 +41,15 @@ if exist ".env" (
) )
echo [信息] 启动服务 http://localhost:%PORT% echo [信息] 启动服务 http://localhost:%PORT%
echo [信息] 调试: 浏览器 F12 打开开发者工具 echo [信息] 改 src 或 skills 后后端会自动重启
echo [信息] 桌面壳: 运行 start-app.bat echo [信息] 后端日志在本窗口,文件在 %USERPROFILE%\.writing-agent\logs\runtime.log
echo [信息] 浏览器按 F12 打开开发者工具
echo [信息] 桌面窗口请用 start-app.bat
echo [信息] 按 Ctrl+C 停止 echo [信息] 按 Ctrl+C 停止
echo. echo.
start "" "http://localhost:%PORT%" start "" "http://localhost:%PORT%"
call npm run web call npm run web:watch
pause pause

View File

@@ -79,6 +79,38 @@ describe("contextSegments assembly", () => {
expect(text).not.toMatch(/^\s*\{/); expect(text).not.toMatch(/^\s*\{/);
}); });
it("revision mode asks the worker to apply sidecar answers and keep the draft", () => {
const bb = new Blackboard();
bb.write({
tag: "设计.美学纲领与交互范式",
content: '{"tone":"warm"}',
source: "worker",
});
const text = assembleWorkerContext({
inputs: {
"用户.修订说明": "再冷一点",
"用户.worker答复": "【追问作答】\n问偏好\n答偏冷",
"设计.美学纲领与交互范式": '{"tone":"warm"}',
},
segments: [
{
id: "user",
tier: "dynamic",
tags: ["用户.worker答复", "用户.修订说明"],
label: "## 用户表述",
},
],
blackboard: bb,
workerId: "design-step",
workerName: "创作 · 美学纲领与交互范式",
outputTags: ["设计.美学纲领与交互范式"],
});
expect(text).toContain("待改底稿");
expect(text).toContain("用户.worker答复");
expect(text).toContain("偏冷");
expect(text).toContain("再冷一点");
});
it("falls back to JSON inputs without segments", () => { it("falls back to JSON inputs without segments", () => {
const bb = new Blackboard(); const bb = new Blackboard();
const text = assembleWorkerContext({ const text = assembleWorkerContext({

View File

@@ -4,6 +4,8 @@ import {
projectFragmentContent, projectFragmentContent,
contextFragmentToView, contextFragmentToView,
extractFragmentAskSidecar, extractFragmentAskSidecar,
isUsableContextFragment,
expectsContextFragmentTag,
} from "../src/skills/context-fragment.js"; } from "../src/skills/context-fragment.js";
import { import {
parseContextOrder, parseContextOrder,
@@ -74,6 +76,26 @@ describe("context-fragment.v1", () => {
"其它", "其它",
]); ]);
}); });
it("isUsableContextFragment requires non-empty 正文", () => {
expect(
isUsableContextFragment({
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "孤立免疫",
: { : { : "特权" } },
}),
).toBe(true);
expect(
isUsableContextFragment({
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "孤立免疫",
}),
).toBe(false);
expect(expectsContextFragmentTag("设计.美学纲领与交互范式")).toBe(true);
expect(expectsContextFragmentTag("设计.创作流程")).toBe(false);
});
}); });
describe("context-order.v1", () => { describe("context-order.v1", () => {

View File

@@ -6,6 +6,7 @@ import {
artifactTagForStep, artifactTagForStep,
extractModuleOpening, extractModuleOpening,
formatCreationFlowForUser, formatCreationFlowForUser,
formatFlowProgressForAgent,
formatModuleCatalogForAgent, formatModuleCatalogForAgent,
formatRecipeCatalogForAgent, formatRecipeCatalogForAgent,
formatSelectedRecipeForAgent, formatSelectedRecipeForAgent,
@@ -14,16 +15,23 @@ import {
loadModuleCatalog, loadModuleCatalog,
loadModulePrompt, loadModulePrompt,
loadRecipeCatalog, loadRecipeCatalog,
mergeCreationFlowPreservingAccepted,
needsFlowExpansion, needsFlowExpansion,
nextPendingStep, nextPendingStep,
parseCreationFlow, parseCreationFlow,
pickRecordedStepId,
parseModuleCatalog, parseModuleCatalog,
parseModuleOpeningState, parseModuleOpeningState,
parseRecipeCatalog, parseRecipeCatalog,
parseRecipeYaml, parseRecipeYaml,
parseSelectedRecipeRef, parseSelectedRecipeRef,
resolveDesignStepBinding, resolveDesignStepBinding,
shouldSkipModuleOpening,
isProgressPointerTag,
stringifyCreationFlow,
creationFlowFromRecipeSeed,
validateCreationFlow, validateCreationFlow,
validateStepReadyToRun,
} from "../src/skills/creation-flow.js"; } from "../src/skills/creation-flow.js";
import { loadWorkerSkillWithContext } from "../src/skills/loader.js"; import { loadWorkerSkillWithContext } from "../src/skills/loader.js";
@@ -67,6 +75,79 @@ describe("creation-flow", () => {
}); });
}); });
it("keeps accepted steps intact when the planner rewrites the flow", () => {
const prev = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }
]
}`;
const rewritten = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": ["实现机制"] },
{ "id": "实现机制", "name": "实现机制", "depends_on": [] }
]
}`;
const merged = mergeCreationFlowPreservingAccepted({
prevRaw: prev,
nextRaw: rewritten,
acceptedStepIds: ["美学纲领与交互范式"],
});
expect(merged.restored).toEqual(["美学纲领与交互范式"]);
const flow = parseCreationFlow(merged.raw)!;
expect(flow.steps[0]?.depends_on).toEqual([]);
expect(flow.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"实现机制",
]);
});
it("re-inserts an accepted step the planner dropped", () => {
const prev = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }
]
}`;
const rewritten = `{
"status": "open",
"steps": [{ "id": "实现机制", "name": "实现机制", "depends_on": [] }]
}`;
const merged = mergeCreationFlowPreservingAccepted({
prevRaw: prev,
nextRaw: rewritten,
acceptedStepIds: ["美学纲领与交互范式"],
});
expect(parseCreationFlow(merged.raw)!.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"实现机制",
]);
});
it("leaves an untouched rewrite alone", () => {
const prev = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }
]
}`;
const rewritten = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式"] }
]
}`;
const merged = mergeCreationFlowPreservingAccepted({
prevRaw: prev,
nextRaw: rewritten,
acceptedStepIds: ["美学纲领与交互范式"],
});
expect(merged.restored).toEqual([]);
expect(merged.raw).toBe(rewritten);
});
it("allows duplicate capability names with distinct ids", () => { it("allows duplicate capability names with distinct ids", () => {
const flow = parseCreationFlow(`{ const flow = parseCreationFlow(`{
"status": "open", "status": "open",
@@ -171,6 +252,29 @@ describe("creation-flow", () => {
}); });
expect(view.steps[2]?.occurrence).toBe(2); expect(view.steps[2]?.occurrence).toBe(2);
expect(view.steps[2]?.repeatable).toBe(true); expect(view.steps[2]?.repeatable).toBe(true);
expect(view.steps[0]?.runState).toBe("current");
expect(view.steps[1]?.runState).toBe("pending");
expect(view.steps[2]?.runState).toBe("pending");
});
it("marks accepted steps done and the next pending current", () => {
const flow = parseCreationFlow(`{
"brief": "网恋回合",
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
]
}`)!;
const view = formatCreationFlowForUser(flow, sampleCatalog, [
"美学纲领与交互范式",
]);
expect(view.steps.map((s) => s.runState)).toEqual([
"done",
"current",
"pending",
]);
}); });
it("loads world-simulator catalog and formats for agent", async () => { it("loads world-simulator catalog and formats for agent", async () => {
@@ -273,6 +377,18 @@ steps:
expect(formatted).toContain("用户已选配方"); expect(formatted).toContain("用户已选配方");
expect(formatted).toContain("核心思路"); expect(formatted).toContain("核心思路");
expect(formatted).toContain("增量"); expect(formatted).toContain("增量");
const seeded = creationFlowFromRecipeSeed(detail);
expect(seeded?.steps[0]?.name).toBe("美学纲领与交互范式");
expect(seeded?.status).toBe("open");
const raw = stringifyCreationFlow(seeded!);
expect(parseCreationFlow(raw)?.steps[0]?.id).toBe("美学纲领与交互范式");
expect(creationFlowFromRecipeSeed({ seed: null })).toBeNull();
expect(
creationFlowFromRecipeSeed({
seed: { version: 1, status: "open", steps: [] },
}),
).toBeNull();
}); });
it("falls back to legacy hint when methodology absent", () => { it("falls back to legacy hint when methodology absent", () => {
@@ -326,9 +442,50 @@ recipes:
expect(selected.promptBody).toContain("核心思路"); expect(selected.promptBody).toContain("核心思路");
expect(selected.promptBody).toContain("何时用"); expect(selected.promptBody).toContain("何时用");
expect(selected.promptBody).toContain("增量"); expect(selected.promptBody).toContain("增量");
expect(selected.promptBody).toContain("【流程进度】");
expect(selected.promptBody).not.toContain("【导演】用户尚未手动选择"); expect(selected.promptBody).not.toContain("【导演】用户尚未手动选择");
}); });
it("tells design-flow which steps are done and not to reschedule them", async () => {
const flowRaw = JSON.stringify({
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
});
const loaded = await loadWorkerSkillWithContext(
"world-simulator",
"design-flow",
undefined,
{
selectedRecipeRef: "world-simulator",
flowRaw,
acceptedStepNames: ["美学纲领与交互范式"],
filledArtifactTags: ["设计.美学纲领与交互范式"],
},
);
expect(loaded.promptBody).toContain("【流程进度】");
expect(loaded.promptBody).toContain("已完成:");
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("已验收");
expect(loaded.promptBody).toContain("禁止再排一次执行");
expect(loaded.promptBody).toContain("生成规则");
});
it("formatFlowProgressForAgent keeps seed steps as draft not new work", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] }
]
}`)!;
const text = formatFlowProgressForAgent({
flow,
acceptedStepIds: [],
catalog: sampleCatalog,
});
expect(text).toContain("草案已有、尚未验收");
expect(text).toContain("美学纲领与交互范式");
expect(text).toContain("不要当作新规划再写一遍");
expect(text).toContain("可反复追加:生成规则、具体实例");
});
it("injects aesthetics-interaction module prompt into design-step", async () => { it("injects aesthetics-interaction module prompt into design-step", async () => {
const flowRaw = JSON.stringify({ const flowRaw = JSON.stringify({
steps: [{ name: "美学纲领与交互范式", depends_on: [] }], steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
@@ -344,6 +501,8 @@ recipes:
}, },
); );
expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式"); expect(loaded.worker.outputTags).toContain("设计.美学纲领与交互范式");
expect(loaded.worker.outputTags).not.toContain("创作.当前步骤");
expect(loaded.worker.inputTags).toContain("创作.当前步骤");
expect(loaded.promptBody).toContain("美学纲领与交互范式"); expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("体验契约"); expect(loaded.promptBody).toContain("体验契约");
expect(loaded.promptBody).toContain("程序开场"); expect(loaded.promptBody).toContain("程序开场");
@@ -386,6 +545,35 @@ recipes:
: "shown", : "shown",
}); });
expect(
shouldSkipModuleOpening({
demand: "丧尸世界只有我不会被感染",
dependsOn: [],
}),
).toBe(true);
expect(
shouldSkipModuleOpening({
demand: "丧尸世界只有我不会被感染",
dependsOn: ["美学纲领与交互范式"],
}),
).toBe(false);
expect(
shouldSkipModuleOpening({
demand: "",
dependsOn: [],
}),
).toBe(false);
expect(
shouldSkipModuleOpening({
demand: "已有需求",
dependsOn: [],
phase: "shown",
}),
).toBe(false);
expect(isProgressPointerTag("创作.当前步骤")).toBe(true);
expect(isProgressPointerTag("设计.美学纲领与交互范式")).toBe(false);
expect(isProgressPointerTag("设计.创作流程")).toBe(false);
const binding = await resolveDesignStepBinding({ const binding = await resolveDesignStepBinding({
skillPackRoot: "dialogue/world-simulator", skillPackRoot: "dialogue/world-simulator",
flowRaw: JSON.stringify({ flowRaw: JSON.stringify({
@@ -428,9 +616,11 @@ recipes:
const missing = parseCreationFlow(`{ const missing = parseCreationFlow(`{
"steps": [{ "name": "生成规则", "depends_on": [] }] "steps": [{ "name": "生成规则", "depends_on": [] }]
}`)!; }`)!;
const bad = validateCreationFlow(missing, catalog); // 编排期允许空壳;必填 params 在确认开干时钉齐
expect(bad.ok).toBe(false); expect(validateCreationFlow(missing, catalog).ok).toBe(true);
expect(bad.errors.some((e) => e.includes("target"))).toBe(true); const stepReady = validateStepReadyToRun(missing.steps[0]!, catalog);
expect(stepReady.ok).toBe(false);
expect(stepReady.errors.some((e) => e.includes("target"))).toBe(true);
const block = formatModuleCatalogForAgent(catalog!); const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("编排参数"); expect(block).toContain("编排参数");
@@ -483,6 +673,43 @@ recipes:
).toBeNull(); ).toBeNull();
}); });
it("pickRecordedStepId ignores LLM junk on 创作.当前步骤", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }
]
}`)!;
expect(
pickRecordedStepId({
flow,
alreadyAccepted: [],
pinnedUnitId: "美学纲领与交互范式",
writtenCurrentStep: JSON.stringify({
schema: "context-fragment.v1",
: { : {}, : {} },
}),
}),
).toBe("美学纲领与交互范式");
expect(
pickRecordedStepId({
flow,
alreadyAccepted: [],
pinnedUnitId: "flow",
writtenCurrentStep: "交互范式",
}),
).toBe("美学纲领与交互范式");
expect(
pickRecordedStepId({
flow,
alreadyAccepted: ["美学纲领与交互范式"],
pinnedUnitId: "",
writtenCurrentStep: "",
}),
).toBe("生成规则");
});
it("open flow needs expansion after listed steps accepted", () => { it("open flow needs expansion after listed steps accepted", () => {
const flow = parseCreationFlow(`{ const flow = parseCreationFlow(`{
"status": "open", "status": "open",

View File

@@ -3,6 +3,7 @@ import {
displayStageLabel, displayStageLabel,
displayWorkerLabel, displayWorkerLabel,
formatWorkerDisplayTitle, formatWorkerDisplayTitle,
reviewComposerCopy,
} from "../src/server/display-labels.js"; } from "../src/server/display-labels.js";
describe("display-labels", () => { describe("display-labels", () => {
@@ -27,4 +28,22 @@ describe("display-labels", () => {
); );
expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述"); expect(displayWorkerLabel("worker:narrator")).toBe("执行单元 · 叙事转述");
}); });
it("maps review composer copy by worker", () => {
const flow = reviewComposerCopy("design-flow");
expect(flow.acceptLabel).toBe("确认编排");
expect(flow.submitLabel).toBe("按意见改编排");
expect(flow.kicker).toBe("核对编排");
expect(flow.tone).toBe("plan");
const step = reviewComposerCopy("design-step");
expect(step.acceptLabel).toBe("接受");
expect(step.submitLabel).toBe("按意见修改");
expect(step.kicker).toBe("待验收");
expect(step.tone).toBe("accept");
const opening = reviewComposerCopy("opening-generator");
expect(opening.acceptLabel).toBe("选定此开场");
expect(opening.kicker).toBe("待选定");
});
}); });

View File

@@ -83,7 +83,7 @@ describe("phase machine", () => {
outputTags: ["设计.worker集.草稿"], outputTags: ["设计.worker集.草稿"],
}); });
session = { ...session, artifacts: [artifact] }; session = { ...session, artifacts: [artifact] };
session = applyEvent(session, { const completed = applyEvent(session, {
type: "worker_completed", type: "worker_completed",
payload: { payload: {
artifactId: artifact.id, artifactId: artifact.id,
@@ -95,12 +95,22 @@ describe("phase machine", () => {
}, },
], ],
}, },
}).session; });
session = completed.session;
expect(session.waitingReason?.kind).toBe("review_artifact"); expect(session.waitingReason?.kind).toBe("review_artifact");
if (session.waitingReason?.kind !== "review_artifact") return; if (session.waitingReason?.kind !== "review_artifact") return;
expect(session.waitingReason.questions?.length).toBe(1); expect(session.waitingReason.questions?.length).toBe(1);
expect(session.waitingReason.questions?.[0]?.required).toBe(false); expect(session.waitingReason.questions?.[0]?.required).toBe(false);
// 追问只挂 waitingReason不另发「可选追问」消息与产物同面展示
expect(
completed.effects.some(
(e) =>
e.type === "emit_message" &&
typeof e.message === "string" &&
/可选追问/.test(e.message),
),
).toBe(false);
// 不答追问,直接接受产物 // 不答追问,直接接受产物
session = applyEvent(session, { session = applyEvent(session, {
@@ -159,4 +169,152 @@ describe("phase machine", () => {
}).session; }).session;
expect(session.slots.designInstanceReady).toBeUndefined(); expect(session.slots.designInstanceReady).toBeUndefined();
}); });
it("rejecting artifact with feedback immediately reruns the same worker", () => {
let session = createSession("default");
const artifact = createArtifact({
workerId: "design-step",
stepId: "step-aesthetics",
outputTags: ["设计.美学纲领"],
});
session = {
...session,
artifacts: [artifact],
pendingArtifactId: artifact.id,
currentStepId: "step-aesthetics",
phase: "waiting_user",
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
slots: {
"用户.worker答复": "【追问作答】\n问偏好\n答偏冷",
},
};
const result = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id, reason: "语气再冷一点" },
});
expect(result.session.phase).toBe("running");
expect(result.session.waitingReason).toBeUndefined();
expect(result.session.slots["用户.修订说明"]).toBe("语气再冷一点");
// 追问答复不得被修订说明冲掉
expect(result.session.slots["用户.worker答复"]).toContain("偏冷");
expect(result.session.artifacts[0]?.status).toBe("revision_requested");
// 只有这一份底稿可在重跑写不出新版时退回
expect(result.session.slots.revisionTargetArtifactId).toBe(artifact.id);
expect(result.session.currentWorkerId).toBe("design-step");
expect(result.session.currentStepId).toBe("step-aesthetics");
expect(result.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("failed revision rerun falls back to reviewing the previous artifact", () => {
let session = createSession("default");
const artifact = {
...createArtifact({
workerId: "design-step",
stepId: "step-aesthetics",
outputTags: ["设计.美学纲领"],
}),
status: "revision_requested" as const,
};
session = {
...session,
artifacts: [artifact],
currentWorkerId: "design-step",
currentStepId: "step-aesthetics",
phase: "running",
slots: { revisionTargetArtifactId: artifact.id },
};
const result = applyEvent(session, {
type: "worker_revision_produced_nothing",
payload: {
artifactId: artifact.id,
questions: ["这一步还没写出可验收的产物。请再补一点你最在意的体验。"],
},
});
expect(result.session.phase).toBe("waiting_user");
expect(result.session.waitingReason?.kind).toBe("review_artifact");
expect(result.session.pendingArtifactId).toBe(artifact.id);
expect(result.session.artifacts[0]?.status).toBe("under_review");
// 重试题降级为可选追问,用户仍可直接接受上一版
const reason = result.session.waitingReason;
expect(reason?.kind === "review_artifact" && reason.questions?.[0]?.required).toBe(
false,
);
// 退回后不再是重跑目标,避免下一步无产物时又被挂回来
expect(result.session.slots.revisionTargetArtifactId).toBeUndefined();
});
it("rejecting artifact without feedback waits for revision instruction", () => {
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 },
};
const rejected = applyEvent(session, {
type: "user_rejected_artifact",
payload: { artifactId: artifact.id },
});
expect(rejected.session.waitingReason?.kind).toBe("revision");
expect(rejected.session.artifacts[0]?.status).toBe("rejected");
const resumed = applyEvent(rejected.session, {
type: "user_submitted_input",
payload: { text: "加一点冲突感" },
});
expect(resumed.session.phase).toBe("running");
expect(resumed.session.slots["用户.修订说明"]).toBe("加一点冲突感");
expect(resumed.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("accepting design-step enters next_intent instead of invoking main agent", () => {
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("next_intent");
expect(accepted.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
const continued = applyEvent(accepted.session, {
type: "user_submitted_input",
payload: { text: "接下来写怪物生成规则" },
});
expect(continued.session.phase).toBe("running");
expect(continued.session.slots["用户.下一步意向"]).toBe(
"接下来写怪物生成规则",
);
expect(continued.effects).toEqual([
{ type: "propose_next_creation_step" },
]);
});
}); });

View File

@@ -1,8 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
appendAskedQuestions,
dropAlreadyAskedQuestions,
formatQuestionAnswersForAi, formatQuestionAnswersForAi,
formatQuestionAnswersForDisplay, formatQuestionAnswersForDisplay,
isModuleOpeningQuestions,
MODULE_OPENING_QUESTION_ID,
mergeQuestionsPreferFragment,
normalizeQuestions, normalizeQuestions,
parseAskedQuestions,
} from "../src/skills/question-protocol.js"; } from "../src/skills/question-protocol.js";
describe("normalizeQuestions", () => { describe("normalizeQuestions", () => {
@@ -28,6 +34,131 @@ describe("normalizeQuestions", () => {
}); });
}); });
describe("isModuleOpeningQuestions", () => {
it("recognizes the single module-opening question", () => {
const qs = normalizeQuestions([
{
id: MODULE_OPENING_QUESTION_ID,
prompt: "本轮生成对象已钉死。请直接谈…",
allowOther: true,
required: true,
},
]);
expect(isModuleOpeningQuestions(qs)).toBe(true);
});
it("rejects ordinary worker questions", () => {
const qs = normalizeQuestions([
{ id: "q1", prompt: "要不要池?" },
]);
expect(isModuleOpeningQuestions(qs)).toBe(false);
expect(isModuleOpeningQuestions([])).toBe(false);
expect(isModuleOpeningQuestions(null)).toBe(false);
});
});
describe("mergeQuestionsPreferFragment", () => {
it("keeps the 示例 version when askUser repeats the same options", () => {
const ask = normalizeQuestions([
{
prompt: "为贴近你要的质感:你最想反复感受到的是哪一种?",
options: [
"隐于都市的日常从容:所有人都只是凡人,而你心里清楚世界是你的",
"身份揭示的瞬间翻转:前一刻还被人平视甚至冒犯,后一刻万人噤声",
],
},
{
prompt: "在你亮明身份之前,你希望普通世界如何对待你?",
options: [
"基本礼貌但无特别敬畏,偶尔会有微小冒犯",
"因你气质不凡而隐隐客气,但没人知道原因",
],
},
]);
const frag = normalizeQuestions([
{
prompt:
"你最想反复感受到的是哪一种?\n示例比如走在现代街头有人撞了你一下。",
options: [
"隐于都市的日常从容:所有人都只是凡人,而你心里清楚世界是你的",
"身份揭示的瞬间翻转:前一刻还被人平视甚至冒犯,后一刻万人噤声",
],
},
{
prompt:
"在你亮明身份之前,你希望普通世界如何对待你?\n示例这样我才好确定叙事里普通人对你的默认距离。",
options: [
"基本礼貌但无特别敬畏,偶尔会有微小冒犯",
"因你气质不凡而隐隐客气,但没人知道原因",
],
},
]);
const merged = mergeQuestionsPreferFragment(ask, frag);
expect(merged).toHaveLength(2);
expect(merged[0]?.prompt).toContain("示例:");
expect(merged[1]?.prompt).toContain("示例:");
});
it("keeps askUser-only questions that are not in the fragment", () => {
const ask = normalizeQuestions([
{ prompt: "人称?", options: ["第二人称", "第一人称"] },
]);
const frag = normalizeQuestions([
{
prompt: "暴力尺度?\n示例锈迹即可",
options: ["少而锋利", "常态压抑"],
},
]);
const merged = mergeQuestionsPreferFragment(ask, frag);
expect(merged).toHaveLength(2);
expect(merged[0]?.prompt).toContain("暴力尺度");
expect(merged[1]?.prompt).toBe("人称?");
});
});
describe("已问追问去重", () => {
const aesthetics = normalizeQuestions([
{
id: "focus",
prompt: "「永远有 1 万元余额」这个设定,你希望体验更偏向哪种?",
options: [
"用这 1 万元在城市里精打细算地活下去",
"靠这 1 万元撬动关系、机会与影响力",
],
},
]);
it("编排层重抛上一步问过的题时整题丢掉", () => {
const history = appendAskedQuestions([], aesthetics);
const replanned = normalizeQuestions([
{
id: "focus",
prompt: "「永远有 1 万元余额」这个设定,你希望体验更偏向哪种?",
options: [
"用这 1 万元在城市里精打细算地活下去",
"靠这 1 万元撬动关系、机会与影响力",
],
},
]);
expect(dropAlreadyAskedQuestions(replanned, history)).toHaveLength(0);
});
it("新问题照常保留", () => {
const history = appendAskedQuestions([], aesthetics);
const fresh = normalizeQuestions([
{ prompt: "下一步先排实现机制还是叙事指南?", options: ["实现机制"] },
]);
expect(dropAlreadyAskedQuestions(fresh, history)).toHaveLength(1);
});
it("留痕可经 slots 往返,且同题不重复堆积", () => {
const once = appendAskedQuestions([], aesthetics);
const twice = appendAskedQuestions(once, aesthetics);
expect(twice).toHaveLength(1);
const roundTripped = parseAskedQuestions(JSON.stringify(twice));
expect(dropAlreadyAskedQuestions(aesthetics, roundTripped)).toHaveLength(0);
});
});
describe("formatQuestionAnswers", () => { describe("formatQuestionAnswers", () => {
it("AI payload keeps Q+A; display omits prompts", () => { it("AI payload keeps Q+A; display omits prompts", () => {
const questions = normalizeQuestions([ const questions = normalizeQuestions([

View File

@@ -5,6 +5,7 @@ import {
MockLlmProvider, MockLlmProvider,
} from "../src/llm/client.js"; } from "../src/llm/client.js";
import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js"; import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "../src/main-agent/tools.js";
import { toolCallToDecision } from "../src/runtime/tool-registry.js"; import { toolCallToDecision } from "../src/runtime/tool-registry.js";
import { createSession } from "../src/runtime/phase-machine.js"; import { createSession } from "../src/runtime/phase-machine.js";
@@ -22,6 +23,12 @@ describe("main agent tool loop", () => {
listArtifacts: () => [], listArtifacts: () => [],
}; };
it("exposes only DAG routing tools", () => {
expect(
MAIN_AGENT_TOOL_DEFINITIONS.map((tool) => tool.function.name),
).toEqual(["read_blackboard", "run_worker"]);
});
it("runs loop tools then returns terminal decision", async () => { it("runs loop tools then returns terminal decision", async () => {
const llm = new MockLlmProvider([ const llm = new MockLlmProvider([
createMockToolCall("read_blackboard", { tags: ["book.brief"] }), createMockToolCall("read_blackboard", { tags: ["book.brief"] }),

View File

@@ -119,4 +119,114 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.askAssessment).toContain("美学纲领 6/10"); expect(result.askAssessment).toContain("美学纲领 6/10");
expect(result.askAssessment).toContain("还需确认"); expect(result.askAssessment).toContain("还需确认");
}); });
it("drops askUser copies of fragment 追问 and keeps the 示例 prompt", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.美学纲领与交互范式": {
schema: "context-fragment.v1",
brief: "孤立免疫",
: { : { : "特权与惊惶" } },
: {
: "还需确认:",
: [
{
: "你最想反复感受到的是哪一种?",
: ["日常从容", "揭示翻转"],
: "袖口唐纹",
},
],
},
},
},
summary: "美学纲领 · 孤立免疫",
askUser: [
{
prompt: "为贴近你要的质感:你最想反复感受到的是哪一种?",
options: ["日常从容", "揭示翻转"],
},
],
}),
["设计.美学纲领与交互范式"],
);
expect(result.askUser).toHaveLength(1);
expect(result.askUser?.[0]?.prompt).toContain("示例:袖口唐纹");
expect(result.askUser?.[0]?.prompt).not.toContain("为贴近你要的质感");
});
it("recovers a root-level context-fragment into the artifact tag", () => {
const frag = {
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "孤立免疫",
: {
: { : { : "丧尸", : "唯我免疫" } },
: { : "完全" },
: { : "人人录我" },
},
};
const result = parseWorkerResponseForTest(JSON.stringify(frag), [
"设计.美学纲领与交互范式",
]);
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("人人录我");
expect(result.askUser).toBeUndefined();
});
it("moves a fragment written to 创作.当前步骤 onto the artifact tag", () => {
const frag = {
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "孤立免疫",
: { : { : "特权与惊惶" } },
};
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: { "创作.当前步骤": frag },
summary: "误写入指针",
}),
["设计.美学纲领与交互范式", "创作.当前步骤"],
);
expect(result.outputs["创作.当前步骤"]).toBeUndefined();
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("特权与惊惶");
});
it("does not accept a fragment without 正文; keeps 追问 for the LLM loop", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.美学纲领与交互范式": {
schema: "context-fragment.v1",
: "美学纲领与交互范式",
brief: "在唯一免疫的末日联接中体验绝望掌控感",
mount: "world-simulator",
: {
: "还差站位",
: [{ : "你代入吗?", : ["完全代入", "旁观"] }],
},
},
},
summary: "半残",
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.askUser?.length).toBeGreaterThan(0);
expect(result.askUser?.[0]?.prompt).toContain("你代入吗");
});
it("asks to continue the loop when fragment JSON is unusable", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.美学纲领与交互范式":
'{"schema":"context-fragment.v1","brief":"孤立免疫","mount":"world-simulator"',
},
summary: "截断",
}),
["设计.美学纲领与交互范式"],
);
expect(result.outputs["设计.美学纲领与交互范式"]).toBeUndefined();
expect(result.askUser?.[0]?.prompt).toContain("还没写出可验收的产物");
});
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -93,3 +93,64 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
if (action === "running") return `${base} · 执行中`; if (action === "running") return `${base} · 执行中`;
return base; return base;
} }
/**
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
*/
export function reviewComposerCopy(workerId, opts = {}) {
const hasQuestions = Boolean(opts.hasQuestions);
const id = String(workerId ?? "").trim();
if (id === "design-flow") {
return {
chip: "编排",
kicker: "核对编排",
taskLabel: "编排",
taskTitle: "核对工作流计划",
hint: hasQuestions
? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。"
: "同意就按这个排;要改就写意见,在现有计划上改。",
submitLabel: "按意见改编排",
acceptLabel: "确认编排",
placeholder: "改编排意见…",
emptyEnterHint: "空 Enter确认编排",
tone: "plan",
kind: "flow",
};
}
if (id === "opening-generator") {
return {
chip: "开场",
kicker: "待选定",
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter选定此开场",
tone: "plan",
kind: "opening",
};
}
return {
chip: "验收",
kicker: "待验收",
taskLabel: "验收",
taskTitle: "验收产物",
hint: hasQuestions
? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。"
: "同意就接受;要改就写意见,在现有产物上改。",
submitLabel: "按意见修改",
acceptLabel: "接受",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter接受",
tone: "accept",
kind: "accept",
};
}

View File

@@ -35,7 +35,7 @@
<div class="rail-panels"> <div class="rail-panels">
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books"> <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="作品路径"></nav> <nav class="path-bar" id="book-path-bar" aria-label="面包屑" hidden></nav>
<div class="book-list" id="book-list"></div> <div class="book-list" id="book-list"></div>
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div> <div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
</div> </div>
@@ -60,14 +60,19 @@
</div> </div>
</div> </div>
<div class="main-head-right"> <div class="main-head-right">
<a class="link" href="/modules.html">技能</a>
<a class="link" href="/shells.html">呈现壳</a>
<a class="link" href="/stats.html">Token</a>
<a class="link" href="/settings.html">设置</a>
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button> <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-save-play" hidden>保存游玩</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</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> <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> </div>
</header> </header>
@@ -77,8 +82,28 @@
<div id="message-feed" class="message-feed"> <div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p> <p class="empty">点击左侧 + 新建作品</p>
</div> </div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div> <div id="questions-card-host" class="questions-card-host" hidden></div>
</section> </section>
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
<button
type="button"
class="coord-rail-toggle"
id="btn-coord-toggle"
aria-expanded="false"
title="展开 / 收起此前对话"
>
<span class="coord-rail-toggle-label">对话</span>
<span id="coord-drawer-count" class="coord-drawer-count"></span>
</button>
<div class="coord-rail-panel">
<header class="coord-rail-head">
<span class="coord-rail-title">此前对话</span>
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话"></button>
</header>
<div id="coord-drawer-body" class="coord-drawer-body"></div>
</div>
</aside>
</div> </div>
<footer class="composer" id="composer"></footer> <footer class="composer" id="composer"></footer>
@@ -119,12 +144,13 @@
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button> <button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button> <button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button> <button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">删除</button> <button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
</menu> </menu>
<dialog id="dialog-context-trace" class="dialog dialog-wide"> <dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form"> <form method="dialog" class="context-trace-form">
<h2>请求上下文</h2> <h2 id="context-trace-title">上下文调用日志</h2>
<p class="dialog-desc" id="context-trace-meta"></p> <p class="dialog-desc" id="context-trace-meta"></p>
<pre id="context-trace-body" class="context-trace-body"></pre> <pre id="context-trace-body" class="context-trace-body"></pre>
<div class="dialog-actions"> <div class="dialog-actions">

View File

@@ -2,10 +2,15 @@
* 结构化询问卡:点字母选中,点文案编辑;左右分页。 * 结构化询问卡:点字母选中,点文案编辑;左右分页。
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。 * 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。 * 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案 * 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。 * 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
*
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
*/ */
/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */
export const MODULE_OPENING_QUESTION_ID = "module-opening";
function esc(s) { function esc(s) {
return String(s ?? "") return String(s ?? "")
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -14,6 +19,22 @@ function esc(s) {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */
export function isModuleOpeningWaiting(view) {
const wr = view?.waitingReason;
if (wr?.kind !== "worker_questions" || !Array.isArray(wr.questions)) return false;
const qs = normalizeQuestions(wr.questions);
return qs.length === 1 && qs[0].id === MODULE_OPENING_QUESTION_ID;
}
/** 取出默认问题正文(供 openingGuide / 说话面展示) */
export function getModuleOpeningPrompt(view) {
if (!isModuleOpeningWaiting(view)) return null;
const qs = normalizeQuestions(view.waitingReason.questions);
const prompt = qs[0]?.prompt?.trim();
return prompt || null;
}
function normalizeQuestions(raw) { function normalizeQuestions(raw) {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw return raw
@@ -64,6 +85,8 @@ export function getActiveQuestions(view) {
// 默认每页 1 题,左右切换;勿一次堆多题 // 默认每页 1 题,左右切换;勿一次堆多题
const pageSize = Math.max(1, Number(wr.pageSize) || 1); const pageSize = Math.max(1, Number(wr.pageSize) || 1);
if (wr.kind === "worker_questions" && wr.questions?.length) { if (wr.kind === "worker_questions" && wr.questions?.length) {
// 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形)
if (isModuleOpeningWaiting(view)) return null;
return { return {
questions: normalizeQuestions(wr.questions), questions: normalizeQuestions(wr.questions),
workerId: wr.workerId, workerId: wr.workerId,
@@ -114,6 +137,18 @@ export function getActiveQuestions(view) {
return null; return null;
} }
/**
* 本轮题是否已答复收起(卡不会再画出来)。
* 此时若仍把主区排成答题面,用户只会看到一片空壳。
*/
export function isQuestionCardDismissed(view, host) {
const el = host ?? document.getElementById("questions-card-host");
const active = getActiveQuestions(view);
if (!el?._qDismissed || !active?.questions?.length) return false;
const key = JSON.stringify(active.questions.map((q) => q.id));
return el._qDismissed.sessionId === view?.id && el._qDismissed.key === key;
}
function persistDraftsFromDom(host, state) { function persistDraftsFromDom(host, state) {
host.querySelectorAll("[data-draft]").forEach((el) => { host.querySelectorAll("[data-draft]").forEach((el) => {
const key = el.getAttribute("data-draft"); const key = el.getAttribute("data-draft");
@@ -224,7 +259,9 @@ export function clearQuestionCardState(host, view) {
host._qState = null; host._qState = null;
host.hidden = true; host.hidden = true;
host.innerHTML = ""; host.innerHTML = "";
host.classList.remove("is-open"); host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
} }
/** /**
@@ -238,7 +275,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
if (!active?.questions?.length) { if (!active?.questions?.length) {
host.hidden = true; host.hidden = true;
host.innerHTML = ""; host.innerHTML = "";
host.classList.remove("is-open"); host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
host._qDismissed = null; host._qDismissed = null;
return false; return false;
} }
@@ -251,7 +290,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
) { ) {
host.hidden = true; host.hidden = true;
host.innerHTML = ""; host.innerHTML = "";
host.classList.remove("is-open"); host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
return false; return false;
} }
@@ -264,11 +305,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
sessionId: view.id, sessionId: view.id,
key: qKey, key: qKey,
page: 0, page: 0,
expanded: false,
// questionId -> { optionId?, text, otherText? } // questionId -> { optionId?, text, otherText? }
answers: {}, answers: {},
drafts: {}, // option key -> edited label drafts: {}, // option key -> edited label
}; };
if (typeof state.expanded !== "boolean") state.expanded = false;
host._qState = state; host._qState = state;
host.classList.toggle("is-expanded", state.expanded);
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } = const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active; active;
@@ -303,20 +347,24 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>` ? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
: ""; : "";
const chromeClass = [ const chromeMods = [
"qcard",
pageCount > 1 ? "has-pager" : "", pageCount > 1 ? "has-pager" : "",
optional ? "has-skip" : "", optional ? "has-skip" : "",
] ].filter(Boolean);
.filter(Boolean)
.join(" "); host.classList.remove("has-pager", "has-skip");
for (const c of chromeMods) host.classList.add(c);
host.setAttribute("role", "dialog");
host.setAttribute("aria-label", "询问");
host.innerHTML = ` host.innerHTML = `
<div class="${chromeClass}" role="dialog" aria-label="询问">
<div class="qcard-float"> <div class="qcard-float">
${pagerHtml} ${pagerHtml}
${skipHtml} ${skipHtml}
</div> </div>
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
</button>
<div class="qcard-body"> <div class="qcard-body">
${dismissNote} ${dismissNote}
${assessmentHtml} ${assessmentHtml}
@@ -364,8 +412,43 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
</section>`; </section>`;
}) })
.join("")} .join("")}
</div> </div>`;
</div>`;
const setExpanded = (next) => {
state.expanded = next;
host.classList.toggle("is-expanded", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "点击收起" : "点击展开";
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);
});
host.querySelector("[data-qskip]")?.addEventListener("click", () => { host.querySelector("[data-qskip]")?.addEventListener("click", () => {
_handlers.onSkipQuestions?.(); _handlers.onSkipQuestions?.();
@@ -401,17 +484,37 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim(); text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
state.answers[qid] = { optionId: oid, text }; state.answers[qid] = { optionId: oid, text };
} }
renderQuestionsCard(host, view, _handlers); // 只切换选中态,避免整卡 innerHTML 重绘把 .qcard-body 滚回顶部
section.querySelectorAll(".qcard-opt").forEach((li) => {
li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid);
});
if (!state.expanded) setExpanded(true);
_handlers.onAnswersChange?.();
}); });
}); });
host.querySelectorAll("[data-other]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
});
host.querySelectorAll("[data-draft]").forEach((el) => { host.querySelectorAll("[data-draft]").forEach((el) => {
el.addEventListener("keydown", (e) => { el.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault(); e.preventDefault();
el.blur(); persistDraftsFromDom(host, state);
} const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
el.blur();
_handlers.onCardEnter?.();
}); });
el.addEventListener("input", () => _handlers.onAnswersChange?.());
el.addEventListener("blur", () => { el.addEventListener("blur", () => {
const key = el.getAttribute("data-draft"); const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? ""; if (key) state.drafts[key] = el.textContent ?? "";

View File

@@ -1,62 +1,124 @@
/* 呈现壳预览页 /* 呈现壳预览页 — 主工作面 = 壳画布;侧栏只负责换壳 */
* 注意:本页也引入 styles.csspresent 样式),会给 body 设 overflow:hidden
* 必须在此覆盖,否则侧栏后几项点不到。
*/
html, html,
body.shells-page { body.shells-page {
height: auto; height: auto;
min-height: 100%; min-height: 100%;
overflow: auto; overflow: auto;
background: var(--st-bg, #f4f4f5);
} }
.shells-page .st-shell.shells-shell { .shells-page .st-shell.shells-shell {
max-width: 1180px; max-width: 1280px;
align-items: start; align-items: start;
grid-template-columns: 200px minmax(0, 1fr);
} }
.shells-page .st-rail { .shells-page .st-rail {
--st-rail-w: 220px; width: 200px;
width: var(--st-rail-w);
position: sticky; position: sticky;
top: var(--st-top-h, 48px); top: var(--st-top-h, 48px);
max-height: calc(100dvh - var(--st-top-h, 48px)); max-height: calc(100dvh - var(--st-top-h, 48px));
overflow-y: auto; overflow-y: auto;
min-height: 0; min-height: 0;
align-self: start; align-self: start;
gap: 2px;
} }
.shells-tone-label { .st-rail-hint {
margin-top: 1rem !important; margin: 0 8px 10px;
}
.shells-density {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
margin: 0.5rem 0 0.35rem;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft, #e8f0fe) 70%, transparent);
font-size: 13px;
line-height: 1.45;
}
.shells-density span {
font-size: 11px; font-size: 11px;
font-weight: 700; line-height: 1.45;
letter-spacing: 0.04em; color: var(--st-faint, #a1a1aa);
color: var(--muted, #666);
text-transform: uppercase;
} }
.shells-list { .shells-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
flex: 0 0 auto; flex: 0 0 auto;
} }
.shells-list .st-rail-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 10px;
line-height: 1.25;
}
.shells-list .shell-item-name {
font-size: 13px;
font-weight: 600;
color: inherit;
}
.shells-list .shell-item-task {
font-size: 11px;
font-weight: 400;
color: var(--st-faint, #a1a1aa);
}
.shells-list .st-rail-item.active .shell-item-task {
color: color-mix(in srgb, var(--st-accent, #2563eb) 65%, #71717a);
}
.shells-main {
min-height: calc(100dvh - var(--st-top-h, 48px));
display: flex;
flex-direction: column;
}
.shells-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 20px;
padding: 14px 24px 12px;
border-bottom: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
position: sticky;
top: var(--st-top-h, 48px);
z-index: 3;
}
.shells-toolbar-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shells-toolbar-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--st-faint, #a1a1aa);
}
.shells-toolbar-meta {
margin-left: auto;
align-items: flex-end;
text-align: right;
max-width: 280px;
}
.shells-toolbar-meta .shells-meta-title {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--st-text, #18181b);
}
.shells-toolbar-meta .shells-meta-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--st-muted, #71717a);
line-height: 1.4;
}
.shells-tones { .shells-tones {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -66,65 +128,102 @@ body.shells-page {
.shells-tone-chip { .shells-tone-chip {
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 5px 10px;
border-radius: 999px; border-radius: 6px;
border: 1px solid var(--border, #ddd); border: 1px solid var(--st-border, #e4e4e7);
background: var(--surface, #fff); background: var(--st-surface, #fff);
cursor: pointer; cursor: pointer;
color: inherit; color: var(--st-muted, #71717a);
}
.shells-tone-chip:hover {
background: #f4f4f5;
color: var(--st-text, #18181b);
}
.shells-tone-chip:focus-visible {
outline: 2px solid var(--st-accent, #2563eb);
outline-offset: 2px;
} }
.shells-tone-chip.active { .shells-tone-chip.active {
border-color: transparent; border-color: transparent;
background: var(--accent-soft, #e8f0fe); background: var(--st-accent-soft, #dbeafe);
color: var(--st-accent, #2563eb);
font-weight: 600; font-weight: 600;
} }
.shells-body { /* 主工作面:尽量大的阅读画布 */
display: flex; .shells-preview-wrap {
flex-direction: column; flex: 1 1 auto;
gap: 1.25rem; padding: 20px 24px 28px;
} background: #ebebed;
.shells-meta p {
margin: 0 0 0.35rem;
line-height: 1.5;
}
.shells-preview-label,
.shells-tweaks h2,
.shells-help h2 {
margin: 0 0 0.5rem;
font-size: 13px;
font-weight: 700;
color: var(--muted, #666);
} }
.shells-preview { .shells-preview {
min-height: 280px; min-height: min(70vh, 720px);
padding: 16px; padding: 20px 16px 28px;
border-radius: 12px; border-radius: 10px;
border: 1px dashed var(--border, #ccc); border: 1px solid var(--st-border, #e4e4e7);
background: background: #f4f4f5;
radial-gradient(ellipse at 20% 0%, rgba(15, 118, 110, 0.06), transparent 50%), overflow: visible;
var(--bg, #f6f6f4);
} }
.shells-preview .present-shell { .shells-preview .present-shell {
max-width: 640px; max-width: 42rem;
margin: 0 auto; margin: 0 auto;
box-shadow: 0 10px 28px rgba(28, 25, 23, 0.07); box-shadow: none;
} }
.shells-preview[data-shell="turn_panel"] .present-shell, .shells-preview[data-shell="turn_panel"] .present-shell,
.shells-preview[data-shell="split_board"] .present-shell, .shells-preview[data-shell="split_board"] .present-shell,
.shells-preview[data-shell="chapter_reader"] .present-shell { .shells-preview[data-shell="chapter_reader"] .present-shell {
max-width: 780px; max-width: 52rem;
} }
/* 预览区不裁切长正文 */ .shells-preview .present-shell-badge {
.shells-preview { display: none;
overflow: visible; }
.shells-details {
margin: 0 24px 28px;
border: 1px solid var(--st-border, #e4e4e7);
border-radius: 8px;
background: var(--st-surface, #fff);
}
.shells-details > summary {
cursor: pointer;
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--st-muted, #71717a);
list-style: none;
}
.shells-details > summary::-webkit-details-marker {
display: none;
}
.shells-details > summary::before {
content: "▸ ";
color: var(--st-faint, #a1a1aa);
}
.shells-details[open] > summary::before {
content: "▾ ";
}
.shells-details[open] > summary {
border-bottom: 1px solid var(--st-border, #e4e4e7);
color: var(--st-text, #18181b);
}
.shells-details-body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
} }
.shells-tweak-grid { .shells-tweak-grid {
@@ -144,30 +243,68 @@ body.shells-page {
font: inherit; font: inherit;
padding: 6px 8px; padding: 6px 8px;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border, #ddd); border: 1px solid var(--st-border, #e4e4e7);
max-width: 320px;
} }
.shells-help ul { .shells-help-list {
margin: 0; margin: 0;
padding-left: 1.2rem; padding-left: 1.15rem;
line-height: 1.55; line-height: 1.55;
font-size: 13px; font-size: 13px;
} color: var(--st-muted, #71717a);
.shells-help .muted {
font-size: 12px;
color: var(--muted, #888);
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.shells-page .st-shell.shells-shell {
grid-template-columns: 1fr;
}
.shells-page .st-rail { .shells-page .st-rail {
position: static; position: static;
max-height: none; max-height: none;
width: auto; width: auto;
overflow: visible; overflow: visible;
border-right: none;
border-bottom: 1px solid var(--st-border, #e4e4e7);
}
.shells-list {
flex-direction: row;
flex-wrap: wrap;
}
.shells-list .st-rail-item {
width: auto;
max-width: 46%;
}
.st-rail-hint,
.st-rail-note {
display: none;
}
.shells-toolbar {
position: static;
}
.shells-toolbar-meta {
margin-left: 0;
text-align: left;
align-items: flex-start;
max-width: none;
width: 100%;
}
.shells-preview-wrap {
padding: 12px;
} }
.shells-preview .present-shell { .shells-preview .present-shell {
max-width: 100%; max-width: 100%;
} }
.shells-details {
margin: 0 12px 20px;
}
} }

View File

@@ -28,44 +28,42 @@
<div class="st-shell shells-shell"> <div class="st-shell shells-shell">
<aside class="st-rail" aria-label="壳列表"> <aside class="st-rail" aria-label="壳列表">
<p class="st-rail-label">呈现</p> <p class="st-rail-label"></p>
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
<nav class="shells-list" id="shell-list" role="tablist"></nav> <nav class="shells-list" id="shell-list" role="tablist"></nav>
<p class="st-rail-label shells-tone-label">装饰语气</p>
<div class="shells-tones" id="tone-list" role="group" aria-label="tone_chrome"></div>
<p class="st-rail-label shells-tone-label">正文长度</p>
<div class="shells-tones" id="length-list" role="group" aria-label="body length"></div>
<p class="st-rail-note"> <p class="st-rail-note">
正文按 600~3000 字设计:随字数撑高、页面滚动;侧栏/选择坞钉住。创作只改内容绑定 主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏
</p> </p>
</aside> </aside>
<main class="st-main"> <main class="st-main shells-main">
<header class="st-main-head"> <div class="shells-toolbar" role="toolbar" aria-label="预览条件">
<div> <div class="shells-toolbar-block">
<h1 id="panel-title">呈现壳预览</h1> <span class="shells-toolbar-label" id="length-label">正文长度</span>
<p class="st-sub" id="panel-subtitle">用示例数据看布局与可调项</p> <div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
</div> </div>
</header> <div class="shells-toolbar-block">
<div class="st-body shells-body"> <span class="shells-toolbar-label" id="tone-label">语气</span>
<section class="shells-meta" id="shell-meta"></section> <div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
<section class="shells-preview-wrap"> </div>
<h2 class="shells-preview-label">预览</h2> <div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
<div id="shell-preview" class="shells-preview"></div>
</section>
<section class="shells-tweaks">
<h2>本页可调(对应创作「微调轴」)</h2>
<div class="shells-tweak-grid" id="tweak-controls"></div>
</section>
<section class="shells-help">
<h2>设计意图</h2>
<ul>
<li>正文默认装得下单轮 <strong>600~3000 字</strong>:区域随内容长高,由页面/消息区滚动,不塞进固定小框。</li>
<li>双栏壳:侧栏 sticky选择坞选项区 sticky 贴底——长文读完控件还在。</li>
<li>壳差在「谁占主柱」;可改的是字段/显示名/行动开关/tone不是重画布局。</li>
</ul>
<p class="muted">说明文档:<code>docs/play-presentation-shells.md</code></p>
</section>
</div> </div>
<section class="shells-preview-wrap" aria-label="壳预览主工作面">
<div id="shell-preview" class="shells-preview"></div>
</section>
<details class="shells-details">
<summary>微调轴与说明</summary>
<div class="shells-details-body">
<div class="shells-tweak-grid" id="tweak-controls"></div>
<ul class="shells-help-list">
<li>主工作面 = 上面的壳。读长文时正文撑高,页面滚动;侧栏 / 选择坞钉住。</li>
<li>壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…</li>
<li>创作只改字段绑定与显示名,不发明新布局。文档:<code>docs/play-presentation-shells.md</code></li>
</ul>
</div>
</details>
</main> </main>
</div> </div>

View File

@@ -1,71 +1,64 @@
/** /**
* 呈现壳预览页:切换壳 / 装饰语气 / 正文长度,用示例数据渲染 present-shells * 呈现壳预览:主工作面 = 壳画布
* 正文设计目标:单轮常见 600~3000 字 —— 随字数撑高,外层滚动;侧栏/选项坞钉住 * 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动
*/ */
import { import {
PRESENT_SHELL_IDS, PRESENT_SHELL_IDS,
renderPresentShellHtml, renderPresentShellHtml,
} from "./present-shells.js"; } from "./present-shells.js";
/** task = 同时可见关系选壳依据name = 短名 */
const SHELL_INFO = { const SHELL_INFO = {
prose: { prose: {
name: "纯散文", name: "纯散文",
blurb: "整块都是正文,无 HUD。长文直接往下滚。", task: "只要正文",
density: "正文随字数伸展;无侧栏争面积", blurb: "无 HUD。读完即止。",
scenes: "轻对话、纯叙事、不要状态栏",
}, },
chat_monitor: { chat_monitor: {
name: "对话 + 监控", name: "对话+监控",
blurb: "顶栏芯片固定矮;主聊/场面再长也只往下长。", task: "正文 + 扫状态",
density: "监控一行;正文撑高滚动", blurb: "顶栏芯片矮;主聊/场面往下长。",
scenes: "网恋 / 日常扮演 / 轻 AIRP",
}, },
spotlight: { spotlight: {
name: "场面主视", name: "场面主视",
blurb: "芯片一行 + 大正文柱。适合一幕写满。", task: "大正文 + 弱状态",
density: "正文主导并伸展;监控不占高", blurb: "无聊天气泡框;场面即画布。",
scenes: "电影感 RP、一幕一景、少机制",
}, },
turn_panel: { turn_panel: {
name: "回合面板", name: "回合面板",
blurb: "场面叙述再长,右侧交互物钉在视口内对照。", task: "场面 + 物 + 行动",
density: "正文撑高;侧栏 sticky;行动在下", blurb: "对照交互物;行动在下",
scenes: "资源、检定、场景交互、跑团感",
}, },
split_board: { split_board: {
name: "双栏看板", name: "双栏看板",
blurb: "左栏长文滚动,右栏线索钉住——对照读。", task: "正文 ‖ 线索",
density: "左正文伸展;右侧栏 sticky", blurb: "左右对照;侧栏钉住。",
scenes: "调查、推理、多线索并行",
}, },
choice_dock: { choice_dock: {
name: "选择坞", name: "选择坞",
blurb: "先读完长局面;选项坞 sticky 贴视口底,不用翻回顶。", task: "局面 + 选项",
density: "正文撑高;底部坞钉住", blurb: "读完再选;坞贴视口底。",
scenes: "选项驱动、AVG、分支关口",
}, },
chapter_reader: { chapter_reader: {
name: "章节阅读", name: "章节阅读",
blurb: "章 + 长阅读柱;窄进度侧栏 sticky。", task: "章正文 + 进度",
density: "正文伸展为主;侧栏窄且钉住", blurb: "阅读柱为主;进度窄栏钉住",
scenes: "长文 / 爽文 / 先纲后章",
}, },
}; };
const TONES = [ const TONES = [
{ id: "default", label: "默认" }, { id: "default", label: "默认" },
{ id: "messenger", label: "讯息" }, { id: "messenger", label: "讯息" },
{ id: "book", label: "书页" }, { id: "book", label: "书页" },
{ id: "terminal", label: "终端" }, { id: "terminal", label: "终端" },
]; ];
const LENGTHS = [ const LENGTHS = [
{ id: "short", label: "短~120" }, { id: "short", label: "短" },
{ id: "mid", label: "中~800" }, { id: "mid", label: "中 ·800" },
{ id: "long", label: "长~2500" }, { id: "long", label: "长 ·2500" },
]; ];
/** 中等长度 ≈800 字 */
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。 const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。 路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
@@ -82,7 +75,6 @@ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`; 门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
/** 长文 ≈2500 字(重复段落拼成,预览用) */
const BODY_LONG = `${BODY_MID} const BODY_LONG = `${BODY_MID}
天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。 天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。
@@ -115,9 +107,7 @@ const BODY_LONG = `${BODY_MID}
「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」 「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你忽然明白,呈现壳要解决的不是「字大一点」,而是这一长段话落在界面里时,读者还愿不愿意往下看——状态栏不能抢戏,选项不能在中途消失,侧栏得钉住好让人对照线索 塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层
你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。
分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」 分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」
@@ -231,7 +221,7 @@ function buildTweaks() {
show_suggested_actions: state.showActions, show_suggested_actions: state.showActions,
block_labels: { ...state.blockLabels }, block_labels: { ...state.blockLabels },
empty_states: { empty_states: {
aside: "(侧栏空:可在创作里改空态文案", aside: "(侧栏空)",
footer: "(文末空)", footer: "(文末空)",
}, },
}; };
@@ -252,27 +242,25 @@ function buildPacket() {
function render() { function render() {
const info = SHELL_INFO[state.shell] || { const info = SHELL_INFO[state.shell] || {
name: state.shell, name: state.shell,
task: "",
blurb: "", blurb: "",
scenes: "",
density: "",
}; };
const packet = buildPacket(); const packet = buildPacket();
document.getElementById("panel-title").textContent = info.name;
document.getElementById("panel-subtitle").textContent = `${info.scenes} · 正文约 ${packet._chars} 字(不计空白)`;
document.getElementById("shell-meta").innerHTML = ` document.getElementById("shell-meta").innerHTML = `
<p><strong>${esc(info.name)}</strong><code>${esc(state.shell)}</code></p> <p class="shells-meta-title">${esc(info.name)}</p>
<p>${esc(info.blurb)}</p> <p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${esc(info.blurb)}</p>
<p class="shells-density"><span>长文策略</span>${esc(info.density)} · 当前试读 <strong>${packet._chars}</strong> 字</p>
<p class="muted">适用:${esc(info.scenes)}。请用上方「正文长度」切到长文,确认侧栏/选项是否仍可用。</p>
`; `;
const preview = document.getElementById("shell-preview"); const preview = document.getElementById("shell-preview");
preview.dataset.shell = state.shell; preview.dataset.shell = state.shell;
preview.innerHTML = renderPresentShellHtml(packet, esc, { tweaks: buildTweaks() }); preview.innerHTML = renderPresentShellHtml(packet, esc, {
tweaks: buildTweaks(),
});
document.querySelectorAll("[data-shell]").forEach((btn) => { document.querySelectorAll("[data-shell]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell); btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell);
btn.setAttribute("aria-selected", btn.getAttribute("data-shell") === state.shell ? "true" : "false");
}); });
document.querySelectorAll("[data-tone]").forEach((btn) => { document.querySelectorAll("[data-tone]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone); btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
@@ -289,8 +277,9 @@ function mount() {
const list = document.getElementById("shell-list"); const list = document.getElementById("shell-list");
list.innerHTML = PRESENT_SHELL_IDS.map((id) => { list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
const info = SHELL_INFO[id]; const info = SHELL_INFO[id];
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab"> return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
${esc(info?.name || id)} <span class="shell-item-name">${esc(info?.name || id)}</span>
<span class="shell-item-task">${esc(info?.task || "")}</span>
</button>`; </button>`;
}).join(""); }).join("");
@@ -307,15 +296,15 @@ function mount() {
document.getElementById("tweak-controls").innerHTML = ` document.getElementById("tweak-controls").innerHTML = `
<label class="field"> <label class="field">
<input type="checkbox" id="tweak-show-actions" /> <input type="checkbox" id="tweak-show-actions" />
显示建议行动」(对应微调轴 show_suggested_actions 显示建议行动show_suggested_actions
</label> </label>
<label class="field"> <label class="field">
正文显示名block_labels.body 正文显示名block_labels.body
<input type="text" id="tweak-label-body" placeholder="例如:短信记录 / 场面 / 本章" /> <input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
</label> </label>
<label class="field"> <label class="field">
监控显示名block_labels.monitor 监控显示名block_labels.monitor
<input type="text" id="tweak-label-monitor" placeholder="例如:状态 / 今日" /> <input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
</label> </label>
`; `;

File diff suppressed because it is too large Load Diff