Add capability-probed structured outputs and a prototype/instance creation graph.

Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 01:57:22 +08:00
co-authored by Cursor
parent 872f742d3e
commit bd5d88abfa
65 changed files with 7625 additions and 942 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
| 产物/技能 | 美化程度 | 渲染入口 |
|-----------|----------|----------|
| 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式)/ 右诊断(设定逻辑) | `renderSpecialtyBodyHtml` → aesthetics |
| 美学纲领与交互范式 | **左右分栏**(左契约可读 / 右诊断密);无工程 banner | `af-split--aesthetics` + `renderAestheticsNodeHtml` |
| 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
+2 -5
View File
@@ -121,8 +121,7 @@
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] },
{ "id": "美学纲领与交互范式·改", "name": "美学纲领与交互范式", "mode": "revise", "revises": "美学纲领与交互范式", "depends_on": ["生成规则#2"] }
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
]
}
```
@@ -134,12 +133,10 @@
| `id` | 本局步骤唯一键;同技能多次必须不同 |
| `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) |
| `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) |
| `mode` | 可选。缺省 / `fresh` = 从零新建;`revise` = 回头修改,继承 `revises` 所指那步的既有产物 |
| `revises` | `mode=revise` 时必填:被改的既有步骤 id(须更前、同 name) |
| `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) |
程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改用 `mode=revise`,不要再排一条 fresh。
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。
〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。
## 2.5 已合并技能(勿拆回)
+8 -4
View File
@@ -29,9 +29,13 @@
推荐游玩调度:`gm → perspective? → narrator → auditor`。
旁观维护与叙事转述默认**不注入**「对话.历史」;主世界层按投影排序表裁剪历史。
## 3. 上下文片段:`context-fragment.v1`
## 3. 上下文片段:`context-fragment.v1` / 扁形 `artifact.flat.v1`
公共外壳(**所有上下文创作技能相同**):
**投递**(全局):按 API profile 探测能力选 `json_schema` → `forced_tool` → `json_object`,与具体技能无关。
**技能稿默认 `context-fragment.v1`**:`正文` 为对象树,深度约 **2 层分组 + 叶子**(短字段、string[],或诊断 `{结论, 依据, 完备度}`)。不要把整柱收成一段散文。扁形 `artifact.flat.v1` 仅为投递兼容;若 `sections[].text` 是 JSON 对象字符串,程序会还原进正文键。
公共外壳:
| 段 | 含义 |
|----|------|
@@ -54,7 +58,7 @@
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段 + **数量**与**一次生成**(单条|同批,同批须钉记录间关系));自评=必要性/属性妥当/格式准确。
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
@@ -65,7 +69,7 @@
开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。
具体实例正文:`rule_id` + `records[]`(单层,键随规则);自评=合规模(只按规则执行)。
具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。
其它技能换掉正文内块名与自评维度名即可,三段外壳不要拆。
## 4. 投影排序:`context-order.v1`(扁平序)
+1 -1
View File
@@ -36,7 +36,7 @@ Book 跨 Session:过程、资产、游玩
新建作品 → UI 选配方 → 用户首句
→ design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed)
→ 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**;已完成节点用回头修改继承旧稿)→ closed
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**)→ closed
→ 收成 设计.worker集(运行规格)→ 验收后才可进 play
→ 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance
→ (无开场白节点时可选)opening-generator 兜底落库
+6 -3
View File
@@ -35,7 +35,7 @@
**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改用**回头修改**(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改:在图上点该节点重进(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
**剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成:
- **工作流计划** = 怎么排技能步骤(DAG)
@@ -181,12 +181,15 @@
|----------------------|----------------|----------|
| `intake` | 描述需求 | 发送 |
| `input` | 补充说明 | 发送 / 补充并发送 |
| `worker_questions` | 答题 | 补充并发送 |
| `approve_step` | 确认 | 同意并开始;回头修改时文案为「回头修改 · {技能名}」 |
| `worker_questions` | 答题 | 补充并发送;已进技能步时旁挂「返回节点」 |
| `approve_step` | 确认 | 同意并开始;重进已完成节点时文案为「回头修改 · {技能名}」;次要为「返回节点」 |
| `pick_creation_step` | 选节点 | 点可进入的节点即开始;要改排在底栏写意见再发 |
| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
| `next_intent` | 说话 | 继续(可留空) |
| `revision` | 说话 | 按意见修改 |
**返回节点**:已点进某步(尤其可增殖原型刚增殖出的实例)但还没写完时,退回分层图。未验收的空实例从图上撤掉,避免误点留下一条空槽。已完成节点重进后返回则保留该节点。勿写成「取消作品」或浏览器后退。
---
## 8. 禁止出现在用户主路径上的写法
+5 -5
View File
@@ -127,7 +127,7 @@ declaration: …
→ 循环直到「接受」
→ 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行)
· 可反复技能再来一次 = 彻底新建(新 id / 新 params)
· 已完成节点要改 = 回头修改(mode=revise,继承旧产物)
· 已完成节点要改 = 用户在图上点该节点(查看产物 / 重新进入修改),不在 DAG 里加修订步
→ 下一节点 opening / design-step …
直到「开场白与开场变量」(〔收口〕)
→ 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿
@@ -135,7 +135,7 @@ declaration: …
「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。
「回头修改」是编排层能力:给已完成步骤追加一条 `mode=revise` 的新步,执行时注入既有产物继续改。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,用回头修改,不必整局重开。
「回头修改」是已完成节点上的动作,不是 DAG 节点:用户点该格,菜单里回看产物或重进继承旧稿。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,点那个已完成节点即可,不必整局重开。
〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。
@@ -188,6 +188,6 @@ recipes/world-simulator|expand-assistant/recipe.yaml
1. 只选配方 → 出**近期**创作流程(`status=open`)
2. design-step 能切割出 `opening`/`task`/…
3. 有 `opening` 时先程序开场再 LLM
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);也可 `mode=revise` 回头修改已完成节点(继承旧产物);收成前 `status=closed`
5. UI 用编排术语;有编排参数的步骤须展示 params;回头修改在流程上标「回头修改」,确认开干文案与「接下来生成」区分
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;回头修改可继承原步 params
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);已完成节点由用户在图上点选重进(继承旧产物);收成前 `status=closed`
5. UI 用编排术语;有编排参数的步骤须展示 params;已完成节点菜单区分「查看产物」与「重新进入修改」
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;重进已完成节点可继承原步 params
+1 -1
View File
@@ -25,7 +25,7 @@ worker-templates/
```text
选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open)
→ 反复 design-step(注入 modules/{id}/prompt.md)
→ 不够则再 design-flow(可追加 repeatable 新建,或 mode=revise 回头修改)→ closed
→ 不够则再 design-flow(可追加 repeatable 新建)→ closed
→ (可选)opening-generator → 手动进 play
```
@@ -51,7 +51,8 @@ feeds: design_only
与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。
产物必须是 **context-fragment.v1** JSON,写入 tag「设计.美学纲领与交互范式」。
业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
业务字段全部放在「正文」内(三柱为**对象**,不要写成一段散文);须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
由运行时按模型能力做 schema/tool 投递,不要再口头包一层 outputs 字符串。
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
@@ -193,17 +194,18 @@ feeds: design_only
硬规则(公共三段 + 本技正文):
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
5. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
6. summary:`美学纲领与交互范式 · {brief 缩略}`。
2. **公共三段**:`正文`=产物主体;`自评`=打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
3. **正文深度**:三柱(设定逻辑 / 交互范式 / 美学纲领)为对象。柱下到「条目」为止:条目是短字段、string[],或诊断三元组 `{结论, 依据, 完备度}` / `{已知, 待探, 完备度}`。诊断里的结论/依据/已知/待探必须是字符串。禁止把整柱写成一段 text;禁止在诊断或短字段里再套对象。
4. **本技三柱**:`设定逻辑`(变造 + 参与方式 + 内容维度 + 区域/复杂性/完备度)+ `交互范式`(前置配置/视角/权限/后果)+ `美学纲领`(体验内核 + 呈现要点)。未知省略或「未定」,禁止编造百科。
5. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
6. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
7. summary:`美学纲领与交互范式 · {brief 缩略}`。
## checklist
```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问 三段外壳?
- [ ] 正文含设定逻辑 + 交互范式 + 美学纲领?自评有交互/美学/协调维度?
- [ ] 正文三柱是对象(非一段散文)?设定逻辑里诊断有结论/依据/完备度?自评有交互/美学/协调维度?
- [ ] 未写 order、未输出执行单元列表?
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
- [ ] 交互范式权限与美学纲领互相保护、不矛盾?
@@ -215,11 +217,12 @@ feeds: design_only
```examples
好:
- brief 点题「垄断生存资源的绝对掌控感」;正文三块齐(设定逻辑含完备度/待探、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳。
- brief 点题「垄断生存资源的绝对掌控感」;正文三柱为对象(设定逻辑含诊断三元组、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳,也不要把三柱收成一段 text。
坏:
- 只有散文纲领,无 JSON / 无自评 / 无追问结构
- 三柱是 sections[].text 一段话,验收卡无法切开诊断与契约
- 百科式背景堆砌,无满足来源与权限边界
- 输出 workers[] 或 order 表
- 追问空洞「你想要什么体验?」无选项
@@ -5,7 +5,7 @@
# declaration = catalog 短声明(可被 prompt.md ```meta 覆盖)
# artifact = 执行期产物 tag(程序映射;不写进流程 JSON)
# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。
# 回头修改已完成节点不是 repeatable:DAG 用 mode=revise + revises,继承旧产物。
# 回头修改已完成节点不是 repeatable:用户在图上点该节点重进,不要排 mode=revise。
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收)
# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行)
@@ -21,7 +21,7 @@
#
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。
# 已完成节点要改:编排 mode=revise(继承旧稿),不要再排一条 fresh。
# 已完成节点要改:用户在图上点该节点重进(继承旧稿),不要再排一条 fresh 或 mode=revise。
# 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。
# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。
@@ -77,7 +77,7 @@ modules:
hint: 本批要锚定什么(如开局重要NPC)
- key: count
label: 数量
hint: 数字或「按规则」;contextual 时写清规模依据
hint: 同批时本批条数;规则「一次生成」为单条时忽略(本步只交 1 条)
- id: narrative
name: 叙事指南与故事推进
@@ -28,10 +28,10 @@ feeds: gm
```opening
先认定本批按哪条规则生成:
- 若【规划产物】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
- 若【本步对象】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
- 若规划为空,从已验收生成规则里选出一条预生成规则。
有批次目标或数量偏好可一句带过;否则直接「按规则生成」。
读规则「一次生成」:同批则本步一次交齐互有关系的多条;单条则本步只交 1 条。有批次目标可一句带过。
```
## task
@@ -41,12 +41,15 @@ feeds: gm
本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【规划产物】当起点;规划可空。
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【本步对象】与【原型建议】;在本步钉「写什么」。
执行顺序:
1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。
2. 数量:规划 `count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(单层 JSON;键=schema 顶层字段)。
2. 条数:先读规则「一次生成」。
- `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。
5. 输出 JSON;追问通常 `[]`。
@@ -56,15 +59,16 @@ summary:`具体实例 · {对象} · {n}条`
## principles
```principles
1. 只按规则来:方法、格式、池、数量、约束——规则有什么用什么;没有的不发明。
2. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
3. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
1. 只按规则来:方法、格式、池、数量、一次生成、约束——规则有什么用什么;没有的不发明。
2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
4. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
```
## probe
```probe
通常不追问。仅当 contextual 数量算不出时问一句规模依据。
通常不追问。仅当 contextual 数量算不出、或同批「关系」写了但本批对象对不齐时,问一句。
用户要 schema 外内容 → 提示回「生成规则」。
```
@@ -110,7 +114,7 @@ summary:`具体实例 · {对象} · {n}条`
硬规则:
1. 合法 JSON;含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`。
2. 正文键固定:`rule_id` / `本批数量` / `批次条件` / `batch_id` / `records` / `增量说明`。
3. `records` 每条必须是规则约定的单层 JSON;`本批数量 === records.length`。
3. `records` 始终为数组;每条必须是规则约定的单层 JSON;`本批数量 === records.length`。规则「一次生成」为单条时长度为 1;为同批时 ≥2 且关系对齐。
4. 自评仅「合规模」一维。禁止改规则、写 `order`、输出执行单元列表。
5. summary:`具体实例 · {对象} · {n}条`。
@@ -118,6 +122,7 @@ summary:`具体实例 · {对象} · {n}条`
```checklist
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组?
- [ ] records 单层、键值符合产物格式与全部约束?
- [ ] 自评为合规模?未改合同?
```
@@ -125,6 +130,8 @@ summary:`具体实例 · {对象} · {n}条`
## examples
```examples
好:规则要 3 条怪物 → 交出 3 条单层 record,字段与池用法全服从规则。
好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
```
@@ -37,13 +37,14 @@ feeds: gm,auditor
```opening
先认定本轮规则写哪一类内容:
- 若【规划产物】已有对象,以此为起点;要改对象就在本步改。
- 若【本步对象】/【原型建议】已有对象提示,以此为起点;要改对象就在本步改。
- 若规划为空,先钉对象族,再谈规则。
然后谈:
1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果?
2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
3. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
3. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。
4. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
想到什么写什么;完整规则在正式产物里钉。
```
@@ -59,7 +60,7 @@ feeds: gm,auditor
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举;
一条规则可多个池;无则 `池: []`。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【规划产物】当起点;规划可空。禁止顺手扩到其它族。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
执行顺序:
1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。
@@ -68,14 +69,17 @@ feeds: gm,auditor
- `runtime_only`:仅游玩期动态生成;不要求「具体实例」。
- `seed_only`:由「具体实例」预生成;规则用完可丢。
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
6. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
4. 钉「数量」与「一次生成」(两件不同的事):
- **数量**:这类对象总共要有几条(fixed / range / contextual)。
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`(一次交出并对齐关系);彼此独立 → `单条`(一次只交 1 条,要更多就再点原型)。产物形状始终是 `records[]`,不因单条改成单对象。
5. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
6. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
7. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
8. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
```
## principles
@@ -90,7 +94,8 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。
6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。
7. 生命周期先行;未定前不假装可执行「具体实例」。
8. 不写变量更新公式、不写剧情推进、不填最终实例。
8. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系(亲属、对立、同场搭档等)→ `同批`;彼此独立 → `单条`。`同批` 至少 2 条,否则改 `单条`。不要把「总共要几个」(数量)和「这一次交几条」(一次生成)混成一件事。
9. 不写变量更新公式、不写剧情推进、不填最终实例。
```
## probe
@@ -98,7 +103,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```probe
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(一次生成:同批 vs 单条);某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
若双门槛不成立 → 建议跳过,停追问字段与格式。
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。
```
@@ -141,6 +146,10 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
"最大": 1,
"决定规则": "contextual 时必填,可较长"
},
"一次生成": {
"模式": "单条|同批",
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」"
},
"生成与描写": {
"依据": ["可较长;引用上游产物或用户要求"],
"方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"],
@@ -236,7 +245,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。
4. **每条 rule 必含**:生成与描写、产物格式;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。
4. **每条 rule 必含**:生成与描写、产物格式、数量、一次生成;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。
6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
@@ -248,7 +257,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
- [ ] 含 schema / brief / 正文 / 自评 / 追问?
- [ ] 自评为必要性 / 属性妥当 / 格式准确?
- [ ] 整条规则必要性有举证?不成立时 rules=[]?
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?数量与一次生成已分清(总共几个 vs 这一次交几条)?同批有关系、单条写彼此独立?
- [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表?
- [ ] 未夹带最终实例、变量公式、其它对象族、百科?
```
@@ -259,11 +268,15 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
好:
- D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。
- 女主规则:不设「性别」字段;关系切面进表;池可空。
- 互为仇敌的两名开局 NPC:一次生成=同批,关系写清对立如何对齐;数量=2。
- 可互换的酒馆路人:一次生成=单条,关系写「彼此独立」;要 5 个就点 5 次。
- 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。
- 普通古董 → 无需专门规则(必要性维度说明跳过理由)。
坏:
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
- 有亲属关系的三人却标成单条,分三次各写一条,关系对不上。
- 把「总共要 5 个」写成一次生成=同批,但彼此并无必须同场的关系。
- 把写好的 NPC 整份塞进池当最终实例。
```
@@ -46,7 +46,7 @@ uiPrompt: |
你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点:
- 没有 DAG 或 DAG 需要追加节点:`design-flow`
- DAG 已给出下一个可执行节点:`design-step`
- 没有 DAG、需要追加节点,或用户要求补前序节点(即使下一步已是收口):`design-flow`
- DAG 已给出下一个可执行节点、且用户没有要求改排:`design-step`
节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。
@@ -3,8 +3,8 @@ id: design-flow
skill: world-simulator
name: 创作 · 流程编排
description: >-
【编排】以用户已选配方为起点,从能力池排出近期工序与依赖,
产出/修订可变增量 DAG(设计.创作流程)。本步不写美学/机制正文,不写 Worker 集。
【编排】以用户已选配方为起点,从能力池排出近期 DAG 骨架(节点 + 依赖 + 原型槽位 + 建议)。
不决定执行顺序;用户在图上点选。本步不写美学/机制正文。
version: 1
stage: design
inputTags:
@@ -40,70 +40,101 @@ contextSegments:
# 创作 · 流程编排
你只做一件事:根据【用户要求】编排或**增量修订**一份剧本流程(可变 DAG)。要求按先后都有效,冲突时以较新的为准。
你只做一件事:根据【用户要求】编排或**增量修订**一份 **DAG 骨架**。要求按先后都有效,冲突时以较新的为准。
**你不决定**用户下一步先跑哪个——用户在分层图上点选。你只产出:有哪些节点、depends_on、原型槽位(repeatable 能力)、以及建议。
上下文里会有:
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。
2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次**新建**编入;已完成节点要改用回头修改(mode=revise)
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、原型槽位。必须先读完再排。
2. **【用户已选配方】**:方法论 + 近期起点——**不是**锁死流水线
3. **【能力 · 可选工序】**:步骤只能从这里选
4. **【已有剧本草案】/【已验收步骤】**:在其上追加,**不要**推倒重来
## 增量 DAG(核心)
**禁止**一次排完全程固定长链。每次只排出**近期要做**的步骤(通常 1~4 步),`status` 默认 `"open"`。
**禁止**一次排完全程固定长链。每次只排出**近期 horizon**(通常 1~4 个节点 + 若干原型),`status` 默认 `"open"`。
选型规则:
### 节点类型
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
- 〔先验产物〕(生成规则、具体实例):可提前规划「写什么」写入 params;执行时程序注入【规划产物】。规划可空——对象在步内钉。提前规划多条(不同对象/批次)是正确的。**禁止**为钉「生成什么 / 调用哪个规则」而 askUser 拦在确认开干前。
- 其它有「编排参数」的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params。
- 用户先认可**下一节点**,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
| 类型 | JSON | 谁跑 |
|------|------|------|
| 普通一步 | 缺省(无 role) | 用户点上直接 design-step |
| **原型槽位** | `"role": "prototype"` | 用户点上 → 程序增殖 instance → design-step |
| **实例** | `"role": "instance"`, `"from": "<原型 id>"` | 仅程序增殖,**禁止**你在编排层预排 |
同能力**可以**多次出现。必须分清两种:
**禁止**排 `mode=revise` / 回头修改节点。已完成节点要改:用户在图上点该节点重进,不在 DAG 里加修订步。
- **彻底新建**(〔可反复〕如生成规则、具体实例):`mode` 缺省或 `"fresh"`,新 id + 新 params。每一次都是新对象/新批次,**不要**改旧条。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
- **回头修改**:用户要改**已经做过**的节点(漏了、谈浅了、后来发现要改)→ 追加新 id,`"mode": "revise"`,`"revises": "<原步 id>"`。程序会注入既有产物,在旧稿上改。非反复技能**禁止**再排一条 fresh;只能 revise。插在最近已验收步之后、尚未执行的步之前。
### 〔可反复〕= 原型,不是多条 instance
生成规则、具体实例等 catalog 标〔可反复〕的能力:
- 在 `steps` 里写 **`role: "prototype"`** 一条即可(id 通常与 name 相同,如 `"生成规则"`)
- 写 **`suggestion`**:建议这条原型将来生成什么(短句,给用户和实例步参考)
- **禁止**在原型上写 `params`
- **禁止**预排 `role: "instance"` 或 `生成规则#2` 这类实例——用户在图上点原型,程序增殖
### `suggestions`(流程级)
除 `steps` 里的原型外,可在顶层写 **`suggestions`**:建议用户**还可以**追加哪些原型(尚未编入 steps 时展示在图上)。
```json
"suggestions": [
{
"name": "生成规则",
"suggestion": "怪物种群与 NPC 两套规则,各开一条",
"depends_on": ["美学纲领与交互范式"]
}
]
```
若已把该能力编入 `steps` 为 prototype,不必在 suggestions 重复。
### 〔先验产物〕
生成规则、具体实例:**「写什么」在实例步内钉**,不在编排层 params。原型只带 `suggestion`。
### 选型规则
- 跟配方 core / process / principles
- 跟能力 meta 的何时用/不用
- 用户要补节点 → 插在未验收步之前、收口之前,`status: "open"`
## 产出(唯一)
写入 tag `设计.创作流程`,必须是 JSON 对象,形状:
写入 tag `设计.创作流程`。根对象就是下面这份 JSON(必须含 `steps`),不要改写成 `artifact.flat.v1` 的 sections。
```json
{
"brief": "一句话复述用户要的体验(可选)",
"brief": "一句话体验(可选)",
"status": "open",
"suggestions": [
{
"name": "具体实例",
"suggestion": "开局 2~3 个重要 NPC",
"depends_on": ["生成规则"]
}
],
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{
"id": "生成规则·怪物",
"id": "生成规则",
"name": "生成规则",
"params": {
"target": "怪物",
"rule_id": "monsters",
"lifecycle_intent": "runtime_only"
},
"role": "prototype",
"suggestion": "先写怪物种群生成规则",
"depends_on": ["美学纲领与交互范式"]
},
{
"id": "具体实例·重要NPC·开局",
"id": "具体实例",
"name": "具体实例",
"params": {
"rule_id": "major-npcs",
"batch_goal": "开局重要NPC",
"count": "按规则"
},
"depends_on": ["生成规则·重要NPC"]
"role": "prototype",
"suggestion": "锚定 1~2 个开局 NPC",
"depends_on": ["生成规则"]
},
{
"id": "美学纲领与交互范式·改",
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["实现机制"]
"id": "开场白与开场变量",
"name": "开场白与开场变量",
"depends_on": ["美学纲领与交互范式", "具体实例"]
}
]
}
@@ -111,44 +142,20 @@ contextSegments:
规则:
1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤)
2. `name` = 能力池里的固定中文名(可重复)
3. `id` = 本局步骤唯一键;同 name 多次时必须不同;新建优先语义化(`生成规则·{对象}`);回头修改用 `{原id}·改`
4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name)
5. `params` = 本步调用参数(对象)。〔先验产物〕:此处是规划产物(可空;已知对象就填,便于区分多条)。其它能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写。回头修改可省略,程序会从 `revises` 那步继承
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
8. 已完成的**非反复**技能禁止再排一条 `mode=fresh`;要改旧稿只能追加 `mode=revise` + `revises`。〔可反复〕再编入新 id 且 fresh = 彻底新建(新对象/新批次),不是改旧条;改旧条同样用 revise
9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
10. **禁止**把「交互」与「美学」拆成两步
11. **禁止**在本步写能力正文、自由 Worker 列表、表结构
12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」→(可开玩时)「开场白与开场变量」。挂谁可早、排第几须晚,勿让中段技能写死 order
13. 「开场白与开场变量」标〔收口〕:排在细化终稿之后作为最后一步;排入后不要再追加其它步,可将 `status` 设为 `closed`。选定开场由程序落库并保存定稿,不必再调 opening-generator
14. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。
只许问**排布层**的事:下一步排哪个能力、普通节点的必填 params 取什么值、是新建还是回头修改哪一步。
〔先验产物〕的「生成什么 / 调用哪个规则」在步内钉,**不要**为此 askUser。
**禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活;
已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」)
15. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
16. `summary`:`流程 · N 步 · open|closed · …`
17. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时(编排修订或步内改规划),须同步改
`params.target`、`params.rule_id`(对应英文 kebab-case,如 `zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。
**禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。
这是**改这条规则的对象**(回头修改或改未验收步),与「再来一条新的生成规则」不是一回事。
1. `steps` 顺序 = 建议阅读顺序;`depends_on` 须指向更前步骤 id
2. `name` = 能力池固定中文名
3. `id` = 本局唯一;原型 id 稳定(如 `生成规则`);实例 id 由程序增殖(`生成规则#1`…)
4. **禁止**在编排层为 repeatable 预排 instance 或写 params
5. 已验收 id **必须保留**
6. 非反复已完成 → 禁止再排新建,**禁止**排 `mode=revise`
7. 收口「开场白与开场变量」排最后;选定前用户补节点 → 插收口前、`status: "open"`
8. askUser 只问排布:补哪个原型、改依赖。**禁止**问体验正文,**禁止**问要不要回头修改
9. `summary`:`流程 · N 步 · open|closed · …`
## 自检
- 用户是否已选配方?(未选则不要硬编)
- 是否只排了近期 horizon,而不是假固定全图?
- 是否误排了多次「发明 Worker」?应改为至多一次「游玩拓扑」
- 每步是否按能力 meta 的何时用/不用判断过,而非题材默认?
- 每步 name 都在【能力】里?同名多次是否都有不同 id?
- 有编排参数的普通步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
- 〔先验产物〕是否误把「生成什么」当成确认开干前必填?规划可空;已知对象才写入 params
- depends_on 是否都指向更靠前的步骤 id?
- 已验收 id 是否都还在?
- 【流程进度】里已完成的非反复技能,是否误当成 fresh 又排了一次?要改旧稿应 mode=revise
- askUser 是否只问了排哪步 / 填哪个参数 / 新建还是回头修改,而不是体验偏好等正文问题?
- 需要反复补同类内容时,是否用了新 id + 新 params + fresh 追加(彻底新建),而不是改写旧步?
- 用户要改已完成节点时,是否用了 mode=revise + revises,而不是再排一条 fresh?
- 收成前是否把 `status` 设为 `closed`?
- 只排了骨架,没有替用户决定顺序?
- repeatable 是否都是 `role=prototype` + suggestion,没有 instance/params?
- suggestions 是否简短、可执行?
- depends_on 是否合法(具体实例原型依赖生成规则原型 = 至少一条规则实例验收后才可增殖)?
- 已验收 id 是否都在?
@@ -42,14 +42,14 @@ contextSegments:
同能力可多次出现(不同 id):
- **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。
- **回头修改**(mode=revise):以【既有产物 · 继承修改】为底稿,改用户点名的部分,保留其余;不从零另起,不再走开场询问。
- **回头修改**(程序注入【既有产物 · 继承修改】):用户从图上点已完成节点重进。以底稿继续改用户点名的部分,保留其余;不从零另起,不再走开场询问。
- **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。
## 执行
1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。
2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。
3. 按方法正文填产物。〔先验产物〕读【规划产物】当起点(可空、可在步内修订);其它节点编排参数见【本步参数】,直接采用。
3. 按方法正文填产物。〔先验产物〕读【本步对象】在本步与用户钉「写什么」;可参考【原型建议】。其它节点编排参数见【本步参数】,直接采用。
4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …`
## 缺料时
@@ -62,4 +62,4 @@ contextSegments:
- 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。
- 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。
需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。
需要追加「再来一次」时,由编排器再调流程编排。已完成节点要改:用户自己在图上点该节点,不要让编排器加修订步。
+79 -28
View File
@@ -1,6 +1,12 @@
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
normalizeCapabilities,
probeProfileCapabilities,
type ProfileCapabilities,
} from "../llm/capabilities.js";
import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
import type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
@@ -11,8 +17,15 @@ export type ApiProfile = {
baseUrl: string;
apiKey: string;
model: string;
/**
* 该配置默认思考强度(写入请求的 reasoning_effort)。
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。
*/
reasoningEffort?: string;
createdAt: string;
updatedAt: string;
/** 能力探测结果;改 baseUrl/model 后应重测 */
capabilities?: ProfileCapabilities;
};
type ApiProfilesFile = {
@@ -45,6 +58,16 @@ function writeFile(data: ApiProfilesFile): void {
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
}
/** 存盘值:合法档位原样保留;空 / auto / 非法 → undefined(不发送) */
export function normalizeProfileReasoningEffort(
value: unknown,
): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed.toLowerCase() === "auto") return undefined;
return sanitizeReasoningEffort(trimmed);
}
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional();
@@ -79,15 +102,18 @@ export function createApiProfile(input: {
baseUrl: string;
apiKey: string;
model: string;
reasoningEffort?: string;
}): ApiProfile {
const data = readFile();
const now = new Date().toISOString();
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
const profile: ApiProfile = {
id: randomUUID(),
name: input.name.trim() || "未命名",
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
apiKey: input.apiKey.trim(),
model: input.model.trim() || "deepseek-v4-pro",
...(reasoningEffort ? { reasoningEffort } : {}),
createdAt: now,
updatedAt: now,
};
@@ -98,20 +124,52 @@ export function createApiProfile(input: {
export function updateApiProfile(
id: string,
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
input: Partial<
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
>,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index];
const nextBase = input.baseUrl?.trim() || current.baseUrl;
const nextModel = input.model?.trim() || current.model;
const endpointChanged =
nextBase !== current.baseUrl || nextModel !== current.model;
const reasoningEffort =
input.reasoningEffort !== undefined
? normalizeProfileReasoningEffort(input.reasoningEffort)
: current.reasoningEffort;
const updated: ApiProfile = {
...current,
name: input.name?.trim() || current.name,
baseUrl: input.baseUrl?.trim() || current.baseUrl,
baseUrl: nextBase,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey,
model: input.model?.trim() || current.model,
model: nextModel,
updatedAt: new Date().toISOString(),
// 端点或模型变了,旧探测作废
...(endpointChanged ? { capabilities: undefined } : {}),
};
if (reasoningEffort) updated.reasoningEffort = reasoningEffort;
else delete updated.reasoningEffort;
data.profiles[index] = updated;
writeFile(data);
return updated;
}
export function saveProfileCapabilities(
id: string,
capabilities: ProfileCapabilities,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]!;
const updated: ApiProfile = {
...current,
capabilities: normalizeCapabilities(capabilities) ?? capabilities,
updatedAt: new Date().toISOString(),
};
data.profiles[index] = updated;
@@ -119,6 +177,20 @@ export function updateApiProfile(
return updated;
}
/** 探测并持久化;返回更新后的 profile */
export async function probeAndSaveProfileCapabilities(
id: string,
): Promise<ApiProfile> {
const profile = getApiProfile(id);
if (!profile) throw new Error("API 配置不存在");
const capabilities = await probeProfileCapabilities({
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model: profile.model,
});
return saveProfileCapabilities(id, capabilities);
}
export function deleteApiProfile(id: string): void {
const data = readFile();
data.profiles = data.profiles.filter((p) => p.id !== id);
@@ -130,30 +202,9 @@ export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model: profile.model,
...(profile.reasoningEffort
? { reasoningEffort: profile.reasoningEffort }
: {}),
capabilities: normalizeCapabilities(profile.capabilities),
};
}
export async function testApiProfile(profile: ApiProfile): Promise<{
ok: boolean;
message: string;
}> {
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${profile.apiKey}` },
});
if (response.ok) {
return { ok: true, message: "连接成功" };
}
const body = await response.text();
return {
ok: false,
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : "连接失败",
};
}
}
+9
View File
@@ -1,10 +1,19 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { ProfileCapabilities } from "../llm/capabilities.js";
export type LlmConfig = {
baseUrl: string;
apiKey: string;
model: string;
/**
* API 配置上的默认思考强度;generation 未指定时写入 reasoning_effort。
* 空 / auto = 不发送,走供应商默认。
*/
reasoningEffort?: string;
/** 来自 API profile 探测结果;决定 structured 投递选路 */
capabilities?: ProfileCapabilities;
};
export function loadLlmConfig(): LlmConfig {
+364
View File
@@ -0,0 +1,364 @@
/**
* API profile 能力探测与 structured 投递选路。
* 优先 response json_schema;不可用则 forced tool;再退 json_object。
*/
import {
applyRejectedGenerationField,
parseRejectedGenerationField,
} from "./generation-compat.js";
export type CapabilityStatus = "ok" | "fail" | "unknown";
export type StructuredDeliveryMode =
| "json_schema"
| "forced_tool"
| "json_object"
| "text";
export type ProfileCapabilities = {
/** 普通聊天 /models 或极简 completion */
chat: CapabilityStatus;
/** response_format: json_object */
jsonObject: CapabilityStatus;
/** response_format: json_schema(真 schema 约束) */
jsonSchema: CapabilityStatus;
/** tools + 返回 tool_calls */
tools: CapabilityStatus;
/** tool_choice=required(或指名函数)能强制提交 */
forcedTool: CapabilityStatus;
/** tools[].function.strict(若探测时带了) */
strictTool: CapabilityStatus;
testedAt?: string;
notes?: string[];
};
export const UNKNOWN_CAPABILITIES: ProfileCapabilities = {
chat: "unknown",
jsonObject: "unknown",
jsonSchema: "unknown",
tools: "unknown",
forcedTool: "unknown",
strictTool: "unknown",
};
/** 探测固定 low,避免 always-on 思考把 max_tokens 吃光导致误判 */
export const CAPABILITY_PROBE_REASONING_EFFORT = "low" as const;
const PROBE_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["ping", "n"],
properties: {
ping: { type: "string" },
n: { type: "number" },
},
} as const;
const PROBE_TOOL = {
type: "function" as const,
function: {
name: "submit_probe",
description: "Submit the probe result. Always call this tool.",
parameters: {
type: "object",
additionalProperties: false,
required: ["ping", "n"],
properties: {
ping: { type: "string" },
n: { type: "number" },
},
},
},
};
const PROBE_TOOL_STRICT = {
type: "function" as const,
function: {
...PROBE_TOOL.function,
strict: true,
},
};
export function normalizeCapabilities(
raw: unknown,
): ProfileCapabilities | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const row = raw as Record<string, unknown>;
const read = (k: string): CapabilityStatus => {
const v = row[k];
if (v === "ok" || v === "fail" || v === "unknown") return v;
return "unknown";
};
return {
chat: read("chat"),
jsonObject: read("jsonObject"),
jsonSchema: read("jsonSchema"),
tools: read("tools"),
forcedTool: read("forcedTool"),
strictTool: read("strictTool"),
testedAt: typeof row.testedAt === "string" ? row.testedAt : undefined,
notes: Array.isArray(row.notes)
? row.notes.filter((x): x is string => typeof x === "string")
: undefined,
};
}
/** 选路:schema → forced tool → json_object → text */
export function resolveStructuredDeliveryMode(
caps: ProfileCapabilities | null | undefined,
): StructuredDeliveryMode {
if (!caps) return "json_object";
if (caps.jsonSchema === "ok") return "json_schema";
if (caps.forcedTool === "ok") return "forced_tool";
if (caps.jsonObject === "ok") return "json_object";
return "text";
}
export function deliveryModeLabel(mode: StructuredDeliveryMode): string {
switch (mode) {
case "json_schema":
return "JSON Schema";
case "forced_tool":
return "强制 Tool";
case "json_object":
return "JSON Object";
default:
return "纯文本";
}
}
type ProbeConfig = {
baseUrl: string;
apiKey: string;
model: string;
};
async function postOnce(
config: ProbeConfig,
body: Record<string, unknown>,
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ model: config.model, ...body }),
});
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text) as unknown;
} catch {
/* ignore */
}
return { ok: res.ok, status: res.status, json, text };
} catch (err) {
return {
ok: false,
status: 0,
json: null,
text: err instanceof Error ? err.message : String(err),
};
}
}
/**
* 探测请求始终带 reasoning_effort=low。
* 若供应商拒收该字段,剥掉后再试一次。
*/
async function postChat(
config: ProbeConfig,
body: Record<string, unknown>,
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
const withEffort: Record<string, unknown> = {
...body,
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
};
const first = await postOnce(config, withEffort);
if (first.ok || first.status !== 400) return first;
const rejected = parseRejectedGenerationField(first.text);
if (rejected !== "reasoning_effort") return first;
const retryBody = { ...withEffort };
const tried = new Set<string>();
if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) {
return first;
}
return postOnce(config, retryBody);
}
function assistantMessage(json: unknown): Record<string, unknown> | null {
if (!json || typeof json !== "object") return null;
const choices = (json as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") {
return null;
}
const msg = (choices[0] as { message?: unknown }).message;
if (!msg || typeof msg !== "object") return null;
return msg as Record<string, unknown>;
}
function hasToolCallNamed(json: unknown, name: string): boolean {
const msg = assistantMessage(json);
if (!msg) return false;
const calls = msg.tool_calls;
if (!Array.isArray(calls)) return false;
return calls.some((c) => {
if (!c || typeof c !== "object") return false;
const fn = (c as { function?: unknown }).function;
if (!fn || typeof fn !== "object") return false;
return (fn as { name?: unknown }).name === name;
});
}
function contentLooksLikeJsonObject(json: unknown): boolean {
const msg = assistantMessage(json);
const content = msg?.content;
if (typeof content !== "string" || !content.trim()) return false;
try {
const parsed = JSON.parse(content) as unknown;
return parsed != null && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/**
* 对单个 profile 跑一轮能力探测(会真实打 API,费用很小)。
*/
export async function probeProfileCapabilities(
config: ProbeConfig,
): Promise<ProfileCapabilities> {
const notes: string[] = [];
const caps: ProfileCapabilities = {
...UNKNOWN_CAPABILITIES,
testedAt: new Date().toISOString(),
};
const chat = await postChat(config, {
messages: [{ role: "user", content: "Reply with exactly: ok" }],
max_tokens: 32,
});
caps.chat = chat.ok ? "ok" : "fail";
if (!chat.ok) {
notes.push(`chat: HTTP ${chat.status} ${chat.text.slice(0, 160)}`);
caps.notes = notes;
return caps;
}
const jsonObject = await postChat(config, {
messages: [
{
role: "user",
content:
'Return a JSON object with keys ping (string "ok") and n (number 1).',
},
],
response_format: { type: "json_object" },
max_tokens: 128,
});
caps.jsonObject =
jsonObject.ok && contentLooksLikeJsonObject(jsonObject.json) ? "ok" : "fail";
if (!jsonObject.ok) {
notes.push(`json_object: HTTP ${jsonObject.status} ${jsonObject.text.slice(0, 120)}`);
} else if (caps.jsonObject === "fail") {
notes.push("json_object: 返回了但不是可解析对象");
}
const jsonSchema = await postChat(config, {
messages: [
{
role: "user",
content: "Fill ping=ok and n=1.",
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "probe_result",
strict: true,
schema: PROBE_SCHEMA,
},
},
max_tokens: 128,
});
caps.jsonSchema =
jsonSchema.ok && contentLooksLikeJsonObject(jsonSchema.json) ? "ok" : "fail";
if (!jsonSchema.ok) {
notes.push(`json_schema: HTTP ${jsonSchema.status} ${jsonSchema.text.slice(0, 120)}`);
}
const tools = await postChat(config, {
messages: [
{
role: "user",
content: "Call submit_probe with ping=ok and n=1.",
},
],
tools: [PROBE_TOOL],
tool_choice: "auto",
max_tokens: 256,
});
caps.tools = tools.ok && hasToolCallNamed(tools.json, "submit_probe") ? "ok" : "fail";
if (!tools.ok) {
notes.push(`tools: HTTP ${tools.status} ${tools.text.slice(0, 120)}`);
} else if (caps.tools === "fail") {
notes.push("tools: 请求成功但未返回 submit_probe");
}
const forced = await postChat(config, {
messages: [
{
role: "user",
content: "Submit the probe.",
},
],
tools: [PROBE_TOOL],
tool_choice: "required",
max_tokens: 256,
});
let forcedOk =
forced.ok && hasToolCallNamed(forced.json, "submit_probe");
if (!forcedOk && forced.ok) {
const named = await postChat(config, {
messages: [{ role: "user", content: "Submit the probe." }],
tools: [PROBE_TOOL],
tool_choice: {
type: "function",
function: { name: "submit_probe" },
},
max_tokens: 256,
});
forcedOk = named.ok && hasToolCallNamed(named.json, "submit_probe");
if (!named.ok) {
notes.push(`forced_tool(named): HTTP ${named.status} ${named.text.slice(0, 120)}`);
}
} else if (!forced.ok) {
notes.push(`forced_tool: HTTP ${forced.status} ${forced.text.slice(0, 120)}`);
}
caps.forcedTool = forcedOk ? "ok" : "fail";
if (caps.tools === "ok" || caps.forcedTool === "ok") {
const strict = await postChat(config, {
messages: [{ role: "user", content: "Submit the probe." }],
tools: [PROBE_TOOL_STRICT],
tool_choice: "required",
max_tokens: 256,
});
caps.strictTool =
strict.ok && hasToolCallNamed(strict.json, "submit_probe") ? "ok" : "fail";
if (!strict.ok) {
notes.push(`strict_tool: HTTP ${strict.status} ${strict.text.slice(0, 120)}`);
}
} else {
caps.strictTool = "fail";
}
caps.notes = notes.length ? notes : undefined;
return caps;
}
+56 -37
View File
@@ -1,11 +1,18 @@
import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js";
import {
applyOpenAiGeneration,
fetchWithGenerationCompat,
sanitizeReasoningEffort,
} from "./generation-compat.js";
import { consumeOpenAiToolStream } from "./stream-complete.js";
import {
currentAbortSignal,
throwIfAborted,
} from "./run-abort.js";
export { sanitizeReasoningEffort };
export type ToolCallPayload = {
id: string;
type: "function";
@@ -30,6 +37,8 @@ export type ToolDefinition = {
name: string;
description: string;
parameters: Record<string, unknown>;
/** 部分供应商支持的严格参数约束 */
strict?: boolean;
};
};
@@ -57,8 +66,16 @@ export type CompleteResult = {
model?: string;
};
export type JsonSchemaFormat = {
name: string;
strict?: boolean;
schema: Record<string, unknown>;
};
export type CompleteOptions = {
responseFormat?: "json_object" | "text";
responseFormat?: "json_object" | "json_schema" | "text";
/** responseFormat=json_schema 时使用 */
jsonSchema?: JsonSchemaFormat;
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
@@ -66,8 +83,15 @@ export type CompleteOptions = {
signal?: AbortSignal;
};
export type ToolChoice =
| "auto"
| "required"
| "none"
| { type: "function"; function: { name: string } };
export type CompleteWithToolsOptions = CompleteOptions & {
tools: ToolDefinition[];
toolChoice?: ToolChoice;
};
export type CompleteWithToolsResult = {
@@ -104,12 +128,11 @@ export type LlmProvider = {
): Promise<CompleteWithToolsResult>;
};
function buildRequestBody(
export function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> {
const gen = options?.generation ?? {};
const body: Record<string, unknown> = {
model: config.model,
messages,
@@ -117,34 +140,28 @@ function buildRequestBody(
if (options?.tools?.length) {
body.tools = options.tools;
body.tool_choice = "auto";
body.tool_choice =
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
}
if (gen.temperature !== undefined) body.temperature = gen.temperature;
else body.temperature = 0.2;
if (gen.topP !== undefined) body.top_p = gen.topP;
if (gen.topK !== undefined) body.top_k = gen.topK;
if (gen.minP !== undefined) body.min_p = gen.minP;
if (gen.frequencyPenalty !== undefined) {
body.frequency_penalty = gen.frequencyPenalty;
}
if (gen.presencePenalty !== undefined) {
body.presence_penalty = gen.presencePenalty;
}
if (gen.repetitionPenalty !== undefined) {
body.repetition_penalty = gen.repetitionPenalty;
}
if (gen.maxOutputTokens !== undefined) {
body.max_tokens = gen.maxOutputTokens;
}
if (gen.seed !== undefined) body.seed = gen.seed;
if (gen.reasoningEffort !== undefined) {
body.reasoning_effort = gen.reasoningEffort;
}
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort,
});
if (options?.responseFormat === "json_object") {
body.response_format = { type: "json_object" };
} else if (
options?.responseFormat === "json_schema" &&
options.jsonSchema
) {
body.response_format = {
type: "json_schema",
json_schema: {
name: options.jsonSchema.name,
strict: options.jsonSchema.strict ?? true,
schema: options.jsonSchema.schema,
},
};
}
if (options?.stream) {
@@ -246,19 +263,21 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
async function llmFetch(
url: string,
apiKey: string,
body: unknown,
body: Record<string, unknown>,
options?: CompleteOptions,
): Promise<Response> {
throwIfAborted(options?.signal);
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: options?.signal ?? currentAbortSignal(),
});
return fetchWithGenerationCompat(async (payload) => {
throwIfAborted(options?.signal);
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
signal: options?.signal ?? currentAbortSignal(),
});
}, body);
}
export class OpenAiCompatibleProvider implements LlmProvider {
+277
View File
@@ -0,0 +1,277 @@
import type { GenerationParameters } from "../types/preset.js";
/**
* OpenAI-compatible Chat Completions 的生成参数兼容层,对齐 SillyTavern Custom 源:
* - `auto` / 关闭值不发送
* - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发
* - 推理模型去掉 temperature / top_p / penalty
* - o 系列与 gpt-5 用 max_completion_tokens
* - 仍 400 时按错误信息剥掉不支持字段再试
*/
const REASONING_EFFORT_VALUES = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
type ReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
const REASONING_EFFORT_ALIASES: Record<string, ReasoningEffort> = {
min: "minimal",
};
const VERBOSITY_VALUES = ["low", "medium", "high"] as const;
type Verbosity = (typeof VERBOSITY_VALUES)[number];
export const STRIPPABLE_GENERATION_FIELDS = new Set([
"temperature",
"top_p",
"top_k",
"min_p",
"frequency_penalty",
"presence_penalty",
"repetition_penalty",
"seed",
"n",
"reasoning_effort",
"verbosity",
"max_tokens",
"max_completion_tokens",
"logit_bias",
"stop",
"response_format",
"stream_options",
]);
export type ChatModelCompat = {
omitSampling: boolean;
useMaxCompletionTokens: boolean;
supportsVerbosity: boolean;
};
function modelLeaf(model: string): string {
const trimmed = model.trim().toLowerCase();
const slash = trimmed.lastIndexOf("/");
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
}
export function classifyChatModel(model: string): ChatModelCompat {
const id = modelLeaf(model);
const isOSeries = /^o[134]($|[-.\d])/.test(id);
const isGpt5 = id.includes("gpt-5");
const isGpt5Chat = isGpt5 && id.includes("chat");
const isKimi25 = id.includes("kimi-k2.5");
const isDeepseekReasoner = id.includes("deepseek-reasoner");
const isGrok3Mini = id.includes("grok-3-mini");
return {
omitSampling:
isOSeries ||
(isGpt5 && !isGpt5Chat) ||
isKimi25 ||
isDeepseekReasoner ||
isGrok3Mini,
useMaxCompletionTokens: isOSeries || isGpt5,
supportsVerbosity: isGpt5,
};
}
export function sanitizeReasoningEffort(
value: unknown,
): ReasoningEffort | undefined {
if (typeof value !== "string") return undefined;
const key = value.trim().toLowerCase();
if (!key || key === "auto") return undefined;
const mapped = REASONING_EFFORT_ALIASES[key] ?? key;
return (REASONING_EFFORT_VALUES as readonly string[]).includes(mapped)
? (mapped as ReasoningEffort)
: undefined;
}
export function sanitizeVerbosity(value: unknown): Verbosity | undefined {
if (typeof value !== "string") return undefined;
const key = value.trim().toLowerCase();
if (!key || key === "auto") return undefined;
return (VERBOSITY_VALUES as readonly string[]).includes(key)
? (key as Verbosity)
: undefined;
}
function finiteNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return value;
}
export type GenerationDefaults = {
/** API profile 等来源的默认思考强度;仅当 gen 未指定时生效 */
reasoningEffort?: string;
};
export function applyOpenAiGeneration(
body: Record<string, unknown>,
gen: GenerationParameters,
model: string,
defaults?: GenerationDefaults,
): void {
const { omitSampling, useMaxCompletionTokens, supportsVerbosity } =
classifyChatModel(model);
if (!omitSampling) {
body.temperature = gen.temperature !== undefined ? gen.temperature : 0.2;
const topP = finiteNumber(gen.topP);
if (topP !== undefined && topP > 0) body.top_p = topP;
const frequencyPenalty = finiteNumber(gen.frequencyPenalty);
if (frequencyPenalty !== undefined) {
body.frequency_penalty = frequencyPenalty;
}
const presencePenalty = finiteNumber(gen.presencePenalty);
if (presencePenalty !== undefined) {
body.presence_penalty = presencePenalty;
}
}
const maxOutput = finiteNumber(gen.maxOutputTokens);
if (maxOutput !== undefined && maxOutput > 0) {
if (useMaxCompletionTokens) body.max_completion_tokens = maxOutput;
else body.max_tokens = maxOutput;
}
const seed = finiteNumber(gen.seed);
if (seed !== undefined && seed >= 0) body.seed = seed;
const reasoningEffort =
sanitizeReasoningEffort(gen.reasoningEffort) ??
sanitizeReasoningEffort(defaults?.reasoningEffort);
if (reasoningEffort !== undefined) {
body.reasoning_effort = reasoningEffort;
}
const verbosity = sanitizeVerbosity(gen.verbosity);
if (verbosity !== undefined && supportsVerbosity) {
body.verbosity = verbosity;
}
}
function readErrorMessage(raw: unknown): string {
if (!raw || typeof raw !== "object") return "";
const err = (raw as { error?: unknown }).error;
if (typeof err === "string") return err;
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") return message;
}
const message = (raw as { message?: unknown }).message;
return typeof message === "string" ? message : "";
}
function readErrorParam(raw: unknown): string | undefined {
if (!raw || typeof raw !== "object") return undefined;
const err = (raw as { error?: unknown }).error;
if (err && typeof err === "object") {
const param = (err as { param?: unknown }).param;
if (typeof param === "string" && STRIPPABLE_GENERATION_FIELDS.has(param)) {
return param;
}
}
return undefined;
}
export function parseRejectedGenerationField(
errorText: string,
): string | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(errorText);
} catch {
parsed = undefined;
}
const fromParam = readErrorParam(parsed);
if (fromParam) return fromParam;
const message = `${readErrorMessage(parsed)}\n${errorText}`;
if (/temperature and top_p cannot both/i.test(message)) return "top_p";
const patterns = [
/unsupported parameter:?\s*['"`]?([a-z_]+)/i,
/unknown field [`'"]([a-z_]+)[`'"]/i,
/([a-z_]+):\s*unknown variant/i,
/invalid value[^\n]*for[^\n]*['"`]([a-z_]+)['"`]/i,
];
for (const pattern of patterns) {
const match = message.match(pattern);
const field = match?.[1]?.toLowerCase();
if (field && STRIPPABLE_GENERATION_FIELDS.has(field)) return field;
}
return undefined;
}
export function applyRejectedGenerationField(
body: Record<string, unknown>,
field: string,
tried: Set<string>,
): boolean {
if (!STRIPPABLE_GENERATION_FIELDS.has(field)) return false;
if (
field === "max_completion_tokens" &&
body.max_completion_tokens != null &&
!tried.has("max_tokens")
) {
body.max_tokens = body.max_completion_tokens;
delete body.max_completion_tokens;
tried.add("max_completion_tokens");
return true;
}
if (
field === "max_tokens" &&
body.max_tokens != null &&
!tried.has("max_completion_tokens")
) {
body.max_completion_tokens = body.max_tokens;
delete body.max_tokens;
tried.add("max_tokens");
return true;
}
if (!(field in body)) return false;
delete body[field];
tried.add(field);
return true;
}
function replayResponse(source: Response, text: string): Response {
return new Response(text, {
status: source.status,
statusText: source.statusText,
headers: source.headers,
});
}
export async function fetchWithGenerationCompat(
send: (body: Record<string, unknown>) => Promise<Response>,
initialBody: Record<string, unknown>,
): Promise<Response> {
let body = initialBody;
const tried = new Set<string>();
let last: Response | undefined;
for (let attempt = 0; attempt < 8; attempt++) {
last = await send(body);
if (last.ok || last.status !== 400) return last;
const text = await last.text();
const field = parseRejectedGenerationField(text);
if (!field) return replayResponse(last, text);
const next = { ...body };
if (!applyRejectedGenerationField(next, field, tried)) {
return replayResponse(last, text);
}
body = next;
}
return last ?? new Response("LLM request failed", { status: 400 });
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 按 profile 能力选路的 structured 完成:json_schema → forced_tool → json_object。
*/
import {
resolveStructuredDeliveryMode,
type ProfileCapabilities,
type StructuredDeliveryMode,
} from "./capabilities.js";
import type {
ChatMessage,
CompleteOptions,
CompleteResult,
LlmProvider,
ToolDefinition,
} from "./client.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
export type StructuredCompleteOptions = CompleteOptions & {
/** JSON Schema 对象(json_schema / tool parameters 共用) */
schema: Record<string, unknown>;
/** schema / tool 名称 */
name: string;
/** 覆盖自动选路 */
mode?: StructuredDeliveryMode;
capabilities?: ProfileCapabilities | null;
/**
* schema 含 additionalProperties:true 等、无法走 strict json_schema 时置 true。
* 会自动降级:forced_tool → json_object。
*/
schemaIsLoose?: boolean;
/** forced_tool 时是否尝试 strict(默认:capabilities.strictTool===ok 且非 loose) */
preferStrictTool?: boolean;
stream?: import("./client.js").StreamCallbacks;
};
export type StructuredCompleteResult = CompleteResult & {
parsed: unknown;
mode: StructuredDeliveryMode;
};
function parseObjectPayload(raw: string): unknown {
const parsed = tryParseJsonDoc(raw);
if (parsed != null) return parsed;
try {
return JSON.parse(raw) as unknown;
} catch {
throw new Error("structured 输出无法解析为 JSON");
}
}
function buildSubmitTool(
name: string,
schema: Record<string, unknown>,
strict: boolean,
): ToolDefinition {
return {
type: "function",
function: {
name,
description: `Submit the structured result as arguments to ${name}. Always call this tool.`,
parameters: schema,
...(strict ? { strict: true } : {}),
},
};
}
/**
* 一次 structured 完成:返回解析后的对象。
* forced_tool 路径走 completeWithTools;其余走 complete / completeStream。
*/
export async function completeStructured(
llm: LlmProvider,
messages: ChatMessage[],
options: StructuredCompleteOptions,
): Promise<StructuredCompleteResult> {
const requested =
options.mode ?? resolveStructuredDeliveryMode(options.capabilities);
let mode = requested;
if (options.schemaIsLoose && mode === "json_schema") {
mode =
options.capabilities?.forcedTool === "ok"
? "forced_tool"
: options.capabilities?.jsonObject === "ok"
? "json_object"
: "text";
}
const schemaName = options.name || "submit_result";
if (mode === "forced_tool") {
const strict =
!options.schemaIsLoose &&
(options.preferStrictTool ??
options.capabilities?.strictTool === "ok");
const tools = [buildSubmitTool(schemaName, options.schema, strict)];
const result = await llm.completeWithTools(messages, {
...options,
tools,
toolChoice: "required",
responseFormat: undefined,
jsonSchema: undefined,
});
const call =
result.toolCalls.find((t) => t.name === schemaName) ??
result.toolCalls[0];
if (!call) {
throw new Error("forced_tool 模式未返回 tool_calls");
}
const parsed = parseObjectPayload(call.arguments);
return {
content: call.arguments,
reasoning: result.reasoning,
usage: result.usage,
model: result.model,
parsed,
mode,
};
}
const completeOpts: CompleteOptions = {
...options,
responseFormat:
mode === "json_schema"
? "json_schema"
: mode === "json_object"
? "json_object"
: "text",
jsonSchema:
mode === "json_schema"
? { name: schemaName, strict: true, schema: options.schema }
: undefined,
};
let result: CompleteResult;
if (options.stream && llm.completeStream) {
result = await llm.completeStream(messages, completeOpts, options.stream);
} else {
result = await llm.complete(messages, completeOpts);
}
const parsed = parseObjectPayload(result.content);
return { ...result, parsed, mode };
}
+7
View File
@@ -46,6 +46,7 @@ const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
worker_questions: "执行单元提问",
revision: "修订",
next_intent: "下一步意向",
pick_creation_step: "点选节点",
};
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
@@ -56,9 +57,14 @@ const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
main_agent_decision_created: "总管决策",
user_approved_next_step: "确认下一步",
user_rejected_next_step: "拒绝下一步",
user_requested_flow_replan: "再编排",
user_picked_creation_step: "点选节点",
creation_step_pick_awaited: "等待点选节点",
user_left_creation_step: "返回节点选择",
worker_started: "执行单元开始",
worker_completed: "执行单元完成",
worker_needs_input: "执行单元提问",
worker_revision_produced_nothing: "修订无新产物",
user_resolved_sidecar_questions: "回答挂载追问",
user_accepted_artifact: "验收通过",
user_rejected_artifact: "打回产物",
@@ -78,6 +84,7 @@ const EFFECT_LABEL: Record<string, string> = {
emit_message: "发消息",
propose_next_creation_step: "提案下一步",
seal_creation_opening: "收口开场白",
unseal_creation_opening: "解开开场收口",
run_play_turn: "开游玩回合",
continue_play_turn: "续游玩管线",
};
+16 -15
View File
@@ -24,25 +24,26 @@ function buildMainAgentSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
- DAG 不存在或需要追加节点:调用 design-flow。
- DAG 中已有下一个可执行节点:调用 design-step。
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
${workerLines}
输出必须是 JSON 对象,字段:
{
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
"reason": "string",
"assessment": "string | null",
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null,
"workerId": "string | null",
"requiresApproval": boolean,
"workerContext": { "roleId": "string" } | null
}`;
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
}
/** 总管把推理写成正文、未给出可用工具/JSON 时,运行时改走点选或 design-flow。 */
export function isUnusableMainAgentOutput(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return (
/invalid JSON/i.test(msg) ||
/no tool calls and no content/i.test(msg) ||
/Invalid action/i.test(msg) ||
/must be an object/i.test(msg) ||
/requires reason/i.test(msg) ||
/multiple terminal tools/i.test(msg) ||
/Not a terminal tool/i.test(msg)
);
}
export class MainAgent {
+2 -7
View File
@@ -42,17 +42,12 @@ function buildToolLoopSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
当前 skill 可用 worker:
${workerLines}
选定节点后立即调用 run_worker。`;
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
}
function executeLoopTool(
+226
View File
@@ -0,0 +1,226 @@
/** 从模型正文里抽出 JSON:先按原文 parse,再宽松修,避免弯引号/围栏/夹叙把合法稿撕开。 */
export function stripCodeFences(text: string): string {
let t = String(text || "").trim();
t = t.replace(/^```(?:json|yaml|yml)?\s*\r?\n?/i, "");
t = t.replace(/\r?\n?```\s*$/i, "");
const fenced = t.match(/```(?:json|yaml|yml)?\s*\r?\n([\s\S]*?)\r?\n```/i);
if (fenced) t = fenced[1]!.trim();
return t.trim();
}
export function escapeRawControlsInJsonStrings(s: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (inString) {
if (escaped) {
out += c;
escaped = false;
continue;
}
if (c === "\\") {
out += c;
escaped = true;
continue;
}
if (c === '"') {
out += c;
inString = false;
continue;
}
if (c === "\n") {
out += "\\n";
continue;
}
if (c === "\r") {
out += "\\r";
continue;
}
if (c === "\t") {
out += "\\t";
continue;
}
out += c;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
out += c;
}
return out;
}
export function convertStructuralSmartQuotes(s: string): string {
const text = String(s || "");
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
const isOpen = c === "\u201C" || c === "\u201E" || c === "\u201F";
const isClose = c === "\u201D" || c === "\u2033" || c === "\u2036";
if (!isOpen && !isClose) {
out += c;
continue;
}
let prev = i - 1;
while (prev >= 0 && /\s/.test(text[prev]!)) prev -= 1;
let next = i + 1;
while (next < text.length && /\s/.test(text[next]!)) next += 1;
const prevCh = prev >= 0 ? text[prev]! : "";
const nextCh = next < text.length ? text[next]! : "";
const afterOpen = !prevCh || "{[:,[".includes(prevCh);
const beforeClose = !nextCh || ":,}]".includes(nextCh);
if (isOpen && afterOpen) out += '"';
else if (isClose && beforeClose) out += '"';
else out += c;
}
return out;
}
export function softenJsonText(s: string): string {
let t = String(s || "")
.replace(/^\uFEFF/, "")
.replace(/\u2026/g, "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
t = t.replace(/^\s*\/\/.*$/gm, "");
t = t.replace(/\/\*[\s\S]*?\*\//g, "");
t = escapeRawControlsInJsonStrings(t);
t = t.replace(/,\s*([\]}])/g, "$1");
return t;
}
export function repairTruncatedJsonObject(slice: string): string {
let s = String(slice || "").trim();
if (!s.startsWith("{")) return s;
s = s.replace(/,\s*"[^"]*$/u, "");
s = s.replace(/,\s*$/u, "");
const opens = (s.match(/\{/g) || []).length;
const closes = (s.match(/\}/g) || []).length;
const openBrackets = (s.match(/\[/g) || []).length;
const closeBrackets = (s.match(/\]/g) || []).length;
const quoteCount = (s.match(/(?<!\\)"/g) || []).length;
if (quoteCount % 2 === 1) s += '"';
if (openBrackets > closeBrackets) s += "]".repeat(openBrackets - closeBrackets);
if (opens > closes) s += "}".repeat(opens - closes);
return s;
}
function tryParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}
const PRODUCT_HINT_KEYS = [
"outputs",
"schema",
"正文",
"body",
"技能",
"workers",
"steps",
"brief",
"追问",
] as const;
export function looksLikeProductJson(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
return PRODUCT_HINT_KEYS.some((k) => k in row);
}
function parseSlice(slice: string): unknown {
return (
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(softenJsonText(convertStructuralSmartQuotes(slice))) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(slice)))
);
}
/** 思维链末尾常整段 JSON;从后往前找像产物的对象,避免吃进前面的举例 `{ }` */
export function extractLastProductJson(text: string): unknown {
const s = String(text || "");
const starts: number[] = [];
for (let i = s.length - 1; i >= 0 && starts.length < 8; i--) {
if (s[i] === "{") starts.push(i);
}
let fallback: unknown = null;
for (const start of starts) {
const slice = s.slice(start);
const end = slice.lastIndexOf("}");
if (end < 1) continue;
const parsed = parseSlice(slice.slice(0, end + 1));
if (parsed == null) continue;
if (looksLikeProductJson(parsed)) return parsed;
if (fallback == null) fallback = parsed;
}
return fallback;
}
/** 模型常把弯引号写进字符串;必须先按原文 parse,再做宽松修复 */
export function tryParseJsonDoc(text: string): unknown {
const stripped = stripCodeFences(text);
const attempts = [
stripped,
softenJsonText(stripped),
softenJsonText(convertStructuralSmartQuotes(stripped)),
];
for (const candidate of attempts) {
const parsed = tryParse(candidate);
if (parsed != null) return parsed;
}
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
) {
const inner = tryParse(cleaned);
if (typeof inner === "string") {
const parsed = tryParse(softenJsonText(inner)) ?? tryParse(inner);
if (parsed != null) return parsed;
}
}
const fromTail = extractLastProductJson(stripped) ?? extractLastProductJson(cleaned);
if (fromTail != null) return fromTail;
const arrStart = cleaned.indexOf("[");
const arrEnd = cleaned.lastIndexOf("]");
if (arrStart >= 0 && arrEnd > arrStart) {
const parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1));
if (parsed != null) return parsed;
}
return null;
}
/**
* 正文可能是闲聊/英文收束,JSON 在思维链里。
* 能 parse 的一边优先;两边都能 parse 时用更像产物的那边。
*/
export function selectJsonPayload(content: string, reasoning?: string): string {
const body = String(content ?? "").trim();
const think = String(reasoning ?? "").trim();
const fromBody = body ? tryParseJsonDoc(body) : null;
const fromThink = think ? tryParseJsonDoc(think) : null;
const bodyOk = fromBody != null && typeof fromBody === "object";
const thinkOk = fromThink != null && typeof fromThink === "object";
if (bodyOk && thinkOk) {
if (looksLikeProductJson(fromThink) && !looksLikeProductJson(fromBody)) {
return think;
}
return body;
}
if (bodyOk) return body;
if (thinkOk) return think;
return body || think;
}
+3
View File
@@ -160,6 +160,9 @@ export class RuntimeOrchestrator {
case "invoke_main_agent":
case "propose_next_creation_step":
case "seal_creation_opening":
case "unseal_creation_opening":
case "run_play_turn":
case "continue_play_turn":
break;
}
}
+167 -10
View File
@@ -35,6 +35,11 @@ import {
SLOT_CREATION_SEALED_BY_OPENING,
isOpeningSealArtifact,
} from "../skills/opening-seal.js";
import {
CREATION_PROPOSED_STEP_TAG,
DESIGN_FLOW_WORKER_ID,
DESIGN_STEP_WORKER_ID,
} from "../skills/creation-flow.js";
import { inferLifecycleStage } from "../skills/worker-declaration.js";
import type { QuestionItem } from "../types/questions.js";
@@ -146,6 +151,54 @@ function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined });
}
/** 回到流程编排:保留已验收步骤,让 design-flow 追加/改排节点 */
function applyFlowReplan(
session: RuntimeSession,
event: RuntimeEvent,
reason?: string,
): ApplyEventResult {
const slots = { ...session.slots };
const note = reason?.trim() ?? "";
if (note) {
slots["用户.下一步意向"] = note;
slots["用户.修订说明"] = note;
}
delete slots[CREATION_PROPOSED_STEP_TAG];
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
let artifacts = session.artifacts;
if (session.pendingArtifactId) {
artifacts = session.artifacts.map((a) =>
a.id === session.pendingArtifactId
? { ...a, status: "rejected" as const, updatedAt: nowIso() }
: a,
);
}
return {
session: touch(
running(
appendHistory(
{
...session,
slots,
artifacts,
pendingDecision: undefined,
pendingArtifactId: undefined,
currentWorkerId: undefined,
resumeContext: undefined,
},
event,
),
),
),
effects: [
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: DESIGN_FLOW_WORKER_ID },
],
};
}
/** 游玩连跑下一执行单元;创作则交总管 */
function afterPlayOrMainAgent(
session: RuntimeSession,
@@ -286,6 +339,7 @@ export function getAllowedEvents(
case "running":
return [
"main_agent_decision_created",
"creation_step_pick_awaited",
"worker_started",
"worker_completed",
"worker_needs_input",
@@ -303,6 +357,9 @@ export function getAllowedEvents(
"user_confirmed_intake",
"user_approved_next_step",
"user_rejected_next_step",
"user_requested_flow_replan",
"user_picked_creation_step",
"user_left_creation_step",
"user_accepted_artifact",
"user_rejected_artifact",
"user_requested_revision",
@@ -341,13 +398,34 @@ export function canApplyEvent(
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
reason?.kind === "revision" ||
reason?.kind === "next_intent"
reason?.kind === "next_intent" ||
reason?.kind === "pick_creation_step"
);
case "user_confirmed_intake":
return reason?.kind === "intake";
case "user_approved_next_step":
case "user_rejected_next_step":
return reason?.kind === "approve_step";
case "user_requested_flow_replan":
return (
reason?.kind === "approve_step" ||
reason?.kind === "review_artifact" ||
reason?.kind === "input" ||
reason?.kind === "worker_questions" ||
reason?.kind === "revision" ||
reason?.kind === "pick_creation_step"
);
case "user_picked_creation_step":
return reason?.kind === "pick_creation_step";
case "user_left_creation_step":
return (
reason?.kind === "approve_step" ||
reason?.kind === "worker_questions" ||
reason?.kind === "input" ||
reason?.kind === "revision"
);
case "creation_step_pick_awaited":
return session.phase === "running";
case "user_accepted_artifact":
case "user_rejected_artifact":
return reason?.kind === "review_artifact";
@@ -361,8 +439,12 @@ export function canApplyEvent(
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
);
case "main_agent_decision_created":
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
return session.phase === "running" || reason?.kind === "input";
// running 时可决策;waiting_user(input/pick) 时允许总管在启动阶段插话或点选后补参确认
return (
session.phase === "running" ||
reason?.kind === "input" ||
reason?.kind === "pick_creation_step"
);
default:
return true;
}
@@ -420,13 +502,20 @@ function handleWorkerCompleted(
if (mode === "no_confirmation") {
next = updateArtifact(next, artifact.id, { status: "accepted" });
return afterPlayOrMainAgent(session, {
const cleared = {
...next,
pendingArtifactId: undefined,
currentWorkerId: undefined,
pendingDecision: undefined,
resumeContext: undefined,
});
};
if (artifact.workerId === DESIGN_FLOW_WORKER_ID) {
return {
session: touch(running(cleared)),
effects: [{ type: "propose_next_creation_step" }],
};
}
return afterPlayOrMainAgent(session, cleared);
}
// programmatic_review
@@ -618,6 +707,10 @@ export function applyEvent(
};
}
if (session.waitingReason?.kind === "pick_creation_step") {
return applyFlowReplan(session, event, text);
}
if (session.waitingReason?.kind === "revision") {
const instruction =
text.trim() || session.waitingReason.instruction?.trim() || "";
@@ -836,16 +929,81 @@ export function applyEvent(
}
case "user_rejected_next_step": {
return applyFlowReplan(session, event, event.payload.reason);
}
case "user_requested_flow_replan": {
return applyFlowReplan(session, event, event.payload.reason);
}
case "creation_step_pick_awaited": {
return {
session: waiting(
touch(
appendHistory(
{
...session,
currentWorkerId: undefined,
pendingDecision: undefined,
},
event,
),
),
{ kind: "pick_creation_step" },
),
effects: [],
};
}
case "user_left_creation_step": {
const slots = { ...session.slots };
delete slots[CREATION_PROPOSED_STEP_TAG];
return {
session: waiting(
touch(
appendHistory(
{
...session,
slots,
pendingDecision: undefined,
pendingArtifactId: undefined,
currentWorkerId: undefined,
currentStepId: undefined,
resumeContext: undefined,
},
event,
),
),
{ kind: "pick_creation_step" },
),
effects: [],
};
}
case "user_picked_creation_step": {
const stepId = event.payload.stepId.trim();
if (!stepId) {
return fail(session, "pick_creation_step requires stepId");
}
return {
session: touch(
running(
appendHistory(
{ ...session, pendingDecision: undefined },
{
...session,
currentWorkerId: DESIGN_STEP_WORKER_ID,
pendingDecision: undefined,
},
event,
),
),
),
effects: [{ type: "invoke_main_agent" }],
effects: [
{
type: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
},
],
};
}
@@ -1033,9 +1191,8 @@ export function applyEvent(
if (inferLifecycleStage(session) === "play") {
return afterPlayOrMainAgent(session, cleared);
}
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
// 创作步验收后停在分层图上等点选(可反复能力可当场新开一条)。
// 仅当没有任何可点节点时,propose 才会把控制权交回总管扩步。
const proposeNext =
artifact.workerId === "design-step" ||
artifact.workerId === "design-flow";
+653 -154
View File
@@ -17,7 +17,11 @@ import {
} from "./phase-machine.js";
import { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.js";
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
import {
DEFAULT_WORKERS,
isUnusableMainAgentOutput,
MainAgent,
} from "../main-agent/main-agent.js";
import { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js";
import {
buildInstanceWorkerDeclaration,
@@ -58,14 +62,32 @@ import {
CREATION_FLOW_TAG,
CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_INHERIT_EXISTING_TAG,
CREATION_MODULE_OPENING_TAG,
CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog,
isReviseStep,
isStepAccepted,
isInheritExistingFlag,
mergeCreationFlowPreservingAccepted,
nextPendingStep,
listReadySteps,
listSelectablePrototypes,
listRepeatableSpawns,
isStepReady,
hasSelectableCreationWork,
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
removeUnstartedInstance,
isPrototypeStep,
isPrototypeSelectable,
isInstanceStep,
findStepByRef,
findModuleByName,
parseCreationFlow,
DESIGN_FLOW_WORKER_ID,
DESIGN_STEP_WORKER_ID,
parseModuleOpeningState,
patchCreationFlowStepParams,
pickRecordedStepId,
@@ -76,7 +98,10 @@ import {
stepUnitId,
stringifyCreationFlow,
stringifyModuleOpeningState,
finalizeCreationFlow,
type CreationFlow,
type CreationFlowStepParams,
type ModuleCatalog,
type ModuleCatalogEntry,
type ModuleParamSpec,
} from "../skills/creation-flow.js";
@@ -98,6 +123,7 @@ import {
OPENING_OUTPUT_TAG,
OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload,
reopenCreationFlowRaw,
SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js";
@@ -326,6 +352,56 @@ export class PhaseRuntime {
throw new Error("当前没有可重试的执行");
}
/** LLM/解析失败后离开 running,避免界面一直转圈。 */
async failRun(reason: string): Promise<RuntimeSession> {
if (this.session.phase !== "running") return this.session;
const result = this.commitEvent({
type: "runtime_failed",
payload: { reason },
});
await this.processEffects(result.effects);
return this.session;
}
/**
* 进程里留下的「执行中、没 worker」:配方已预置 DAG 时改回点选等待。
* 读档 / 轮询时调用,避免界面一直转圈。
*/
recoverOrphanedRun(): boolean {
if (this.session.phase !== "running" || this.session.waitingReason) {
return false;
}
if (this.session.currentWorkerId) return false;
if (!this.hasCreationFlow()) return false;
debugLog("step", "恢复中断的调度,等待点选节点");
this.clearProposedStep();
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
pendingDecision: undefined,
currentWorkerId: undefined,
updatedAt: new Date().toISOString(),
};
return true;
}
/**
* 配方已预置 DAG:跳过「先描述需求」,直接进入点选图。
* 图上节点来自当前剧本 seed,换配方即换开局。
*/
async enterSeededCreationPick(): Promise<void> {
if (!this.hasCreationFlow()) return;
this.session = {
...this.session,
slots: {
...this.session.slots,
startupCompleted: true,
},
};
await this.awaitCreationStepPick();
}
/** 重 roll 指定 worker(刷新 Skill 回复) */
async rerunWorker(workerId: string): Promise<RuntimeSession> {
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
@@ -595,7 +671,7 @@ export class PhaseRuntime {
return this.session;
}
/** 用户拒绝 pendingDecision,回到总管 */
/** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */
async rejectStep(reason?: string): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待拒绝的决策");
@@ -606,6 +682,180 @@ export class PhaseRuntime {
return this.session;
}
/**
* 离开当前技能步,回到分层图。
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
*/
async leaveCreationStep(): Promise<RuntimeSession> {
const waiting = this.session.waitingReason?.kind;
const allowed =
waiting === "worker_questions" ||
waiting === "input" ||
waiting === "revision" ||
waiting === "approve_step";
if (!allowed) {
throw new Error("当前不能返回节点选择");
}
if (inferLifecycleStage(this.session) === "play") {
throw new Error("游玩中不能返回创作节点");
}
const stepId = this.readPinnedStepId();
const removed = this.unspawnCurrentInstanceIfEmpty();
this.clearProposedStep();
this.clearCurrentStepPointers();
const result = await this.dispatch({
type: "user_left_creation_step",
payload: {},
});
if (result.error) {
throw new Error(result.error);
}
const stepName = removed?.name?.trim();
this.onMessage(
removed
? `已取消刚增殖的「${stepName || stepId}」,回到节点选择。`
: "已回到节点选择。",
);
return this.session;
}
private unspawnCurrentInstanceIfEmpty(): { id: string; name: string } | null {
const stepId = this.readPinnedStepId();
if (!stepId) return null;
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return null;
const step = findStepByRef(flow, stepId);
if (!step || !isInstanceStep(step)) return null;
const accepted = this.readAcceptedStepIds();
if (isStepAccepted(step, accepted)) return null;
if (
this.session.artifacts.some(
(a) => a.stepId === step.id && a.status !== "rejected",
)
) {
return null;
}
const next = removeUnstartedInstance(flow, step.id, accepted);
if (next.steps.length === flow.steps.length) return null;
this.writeCreationFlow(next);
debugLog("step", `取消未完成实例 ${step.id}`);
return { id: step.id, name: step.name };
}
private clearCurrentStepPointers(): void {
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
content: "",
source: "runtime",
});
this.blackboard.write({
tag: CREATION_INHERIT_EXISTING_TAG,
content: "",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_CURRENT_STEP_TAG]: undefined,
[CREATION_INHERIT_EXISTING_TAG]: undefined,
[SLOT_CREATION_CURRENT_UNIT]: undefined,
},
};
}
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
async replanCreationFlow(reason?: string): Promise<RuntimeSession> {
const waiting = this.session.waitingReason?.kind;
if (waiting === "approve_step") {
return this.rejectStep(reason);
}
await this.dispatch({
type: "user_requested_flow_replan",
payload: { reason },
});
return this.session;
}
/** 图上点选:普通节点开干;原型槽位先增殖再进去;已完成节点可 reenter 重进 */
async pickCreationStep(
stepId: string,
stepParams?: CreationFlowStepParams,
opts?: { reenter?: boolean },
): Promise<RuntimeSession> {
const id = stepId.trim();
this.syncSlotsToBlackboard(this.session);
const catalog = await this.loadActiveModuleCatalog();
const flow = this.readNormalizedFlow(catalog);
const step = flow ? findStepByRef(flow, id) : null;
if (flow && step && isPrototypeStep(step)) {
if (opts?.reenter) {
throw new Error("原型槽位不能回头修改,请点已完成的实例");
}
const spawned = spawnInstanceFromPrototype({
flow,
catalog,
prototypeId: step.id,
acceptedStepIds: this.readAcceptedStepIds(),
});
if ("error" in spawned) throw new Error(spawned.error);
this.writeCreationFlow(spawned.flow);
debugLog("step", `点原型 ${step.id} → ${spawned.step.id}`);
await this.startCreationStep(spawned.step.id, { stepParams });
return this.session;
}
await this.startCreationStep(id, {
stepParams,
reenter: opts?.reenter === true,
});
return this.session;
}
/** 可反复能力:新编一条再进去 */
async spawnCreationStep(moduleName: string): Promise<RuntimeSession> {
this.syncSlotsToBlackboard(this.session);
const catalog = await this.loadActiveModuleCatalog();
const flow = this.readNormalizedFlow(catalog);
if (!flow) throw new Error("还没有工作流计划,无法追加节点");
const spawned = spawnRepeatableCreationStep({
flow,
catalog,
moduleName,
acceptedStepIds: this.readAcceptedStepIds(),
});
if ("error" in spawned) throw new Error(spawned.error);
this.writeCreationFlow(spawned.flow);
debugLog("step", `新开 ${spawned.step.id}(${spawned.step.name})`);
await this.startCreationStep(spawned.step.id);
return this.session;
}
private writeCreationFlow(flow: CreationFlow): void {
const content = stringifyCreationFlow(flow);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
}
private readNormalizedFlow(catalog: ModuleCatalog | null): CreationFlow | null {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return null;
const next = finalizeCreationFlow(flow, catalog);
const changed = JSON.stringify(next.steps) !== JSON.stringify(flow.steps);
if (changed) this.writeCreationFlow(next);
return next;
}
/** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */
private persistInstanceWorkerDeclaration(): void {
const active = this.getActiveSkill();
@@ -857,6 +1107,13 @@ export class PhaseRuntime {
}
break;
case "run_worker":
if (
effect.workerId === DESIGN_STEP_WORKER_ID &&
!this.hasPinnedReadyStep()
) {
await this.awaitCreationStepPick();
break;
}
if (this.autoStubWorker) {
await this.runStubWorker(effect);
} else if (this.llm) {
@@ -894,6 +1151,9 @@ export class PhaseRuntime {
case "seal_creation_opening":
this.sealCreationOpening();
break;
case "unseal_creation_opening":
this.unsealCreationOpening();
break;
case "run_play_turn":
await this.startPlayTurn();
break;
@@ -905,9 +1165,243 @@ export class PhaseRuntime {
}
/**
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
* 用户填完「下一步想写什么」后:有可点节点则停在分层图;程序步自动开干;否则交总管扩步。
*/
private async proposeNextCreationStep(): Promise<void> {
await this.awaitCreationStepPick();
}
private readAcceptedStepIds(): string[] {
return parseAcceptedUnits(
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
}
private async loadActiveModuleCatalog(): Promise<ModuleCatalog | null> {
const active = this.getActiveSkill();
if (!active?.name) return null;
try {
const skill = await loadSkill(active.name);
if (!skill.skillPackRoot) return null;
return await loadModuleCatalog(skill.skillPackRoot);
} catch {
return null;
}
}
private readPinnedStepId(): string {
const fromCurrent = this.blackboard
.getContentByTag(CREATION_CURRENT_STEP_TAG)
?.trim();
if (fromCurrent) return fromCurrent;
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 "";
try {
const doc = JSON.parse(raw) as { stepId?: unknown };
return typeof doc.stepId === "string" ? doc.stepId.trim() : "";
} catch {
return "";
}
}
private hasPinnedReadyStep(): boolean {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) return false;
const step = findStepByRef(flow, this.readPinnedStepId());
if (!step) return false;
return isStepReady(flow, step, this.readAcceptedStepIds());
}
private pinCreationStepSnapshot(params: {
stepId: string;
name: string;
declaration: string;
params: Record<string, unknown>;
paramsMissing: string[];
paramSpecs: ModuleParamSpec[];
kind?: ModuleCatalogEntry["kind"];
closer?: boolean;
revise: boolean;
revises?: string;
}): void {
const intent =
typeof this.session.slots["用户.下一步意向"] === "string"
? String(this.session.slots["用户.下一步意向"]).trim()
: "";
const snapshot = {
stepId: params.stepId,
name: params.name,
declaration: params.declaration || undefined,
params: params.params,
paramsMissing: params.paramsMissing,
paramSpecs: params.paramSpecs.map((p) => ({
key: p.key,
label: p.label,
required: Boolean(p.required),
hint: p.hint,
})),
intent: intent || undefined,
...(params.kind ? { kind: params.kind } : {}),
...(params.closer ? { closer: true } : {}),
...(params.revise ? { mode: "revise" as const } : {}),
...(params.revises ? { revises: params.revises } : {}),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
content: params.stepId,
source: "runtime",
});
this.blackboard.write({
tag: CREATION_INHERIT_EXISTING_TAG,
content: params.revise ? "1" : "",
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
[CREATION_CURRENT_STEP_TAG]: params.stepId,
[CREATION_INHERIT_EXISTING_TAG]: params.revise ? "1" : "",
},
};
}
private async startCreationStep(
stepId: string,
opts?: { stepParams?: CreationFlowStepParams; reenter?: boolean },
): Promise<void> {
this.syncSlotsToBlackboard(this.session);
let flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) throw new Error("还没有工作流计划");
let step = findStepByRef(flow, stepId);
if (!step) throw new Error(`找不到节点:${stepId}`);
const accepted = this.readAcceptedStepIds();
const catalog = await this.loadActiveModuleCatalog();
const reenter = opts?.reenter === true;
if (isPrototypeStep(step)) {
if (!isPrototypeSelectable(flow, step, accepted)) {
throw new Error(`「${step.name}」的前置还没齐,不能增殖`);
}
const spawned = spawnInstanceFromPrototype({
flow,
catalog,
prototypeId: step.id,
acceptedStepIds: accepted,
});
if ("error" in spawned) throw new Error(spawned.error);
const content = stringifyCreationFlow(spawned.flow);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
flow = spawned.flow;
step = spawned.step;
} else if (reenter) {
if (!isStepAccepted(step, accepted)) {
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
}
} else if (!isStepReady(flow, step, accepted)) {
throw new Error(`「${step.name}」的前置节点还没完成,不能开始`);
}
const pendingModule = findModuleByName(catalog, step.name);
const paramSpecs = pendingModule?.params ?? [];
const auto = pendingModule?.auto === true;
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
const next = patchCreationFlowStepParams(flow, step.id, opts.stepParams);
const content = stringifyCreationFlow(next);
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content,
source: "user",
});
this.session = {
...this.session,
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
};
}
const latest = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const pinned = latest ? findStepByRef(latest, step.id) ?? step : step;
const params = pinned.params ? { ...pinned.params } : {};
const paramsMissing = missingRequiredStepParams(pinned, pendingModule);
this.pinCreationStepSnapshot({
stepId: pinned.id,
name: pinned.name,
declaration: pendingModule?.declaration?.trim() || "",
params,
paramsMissing,
paramSpecs,
kind: pendingModule?.kind,
closer: pendingModule?.closer === true,
revise: reenter || isReviseStep(pinned),
revises: pinned.revises,
});
const waitingPick =
this.session.waitingReason?.kind === "pick_creation_step";
const needsParamForm = paramsMissing.length > 0 && !auto;
if (needsParamForm) {
const decision = createDecision({
action: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
reason: `「${pinned.name}」还缺参数:${paramsMissing.join("、")}`,
requiresApproval: true,
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
return;
}
if (waitingPick) {
await this.dispatch({
type: "user_picked_creation_step",
payload: { stepId: pinned.id },
});
return;
}
const decision = createDecision({
action: "run_worker",
workerId: DESIGN_STEP_WORKER_ID,
reason: reenter || isReviseStep(pinned)
? `回头修改 · ${pinned.name}`
: `开始:${pinned.name}`,
requiresApproval: false,
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
}
/**
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
*/
private async awaitCreationStepPick(): Promise<void> {
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
return;
@@ -916,121 +1410,67 @@ export class PhaseRuntime {
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(
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
);
}
const accepted = this.readAcceptedStepIds();
if (!flow) {
debugLog("step", "暂无可用流程,等待点选或改编排");
await this.enterCreationStepPickWait();
return;
}
const active = this.getActiveSkill();
let paramSpecs: ModuleParamSpec[] = [];
let declaration = "";
let auto = false;
let kind: ModuleCatalogEntry["kind"];
let pendingModule: ModuleCatalogEntry | null = null;
if (active?.name) {
try {
const skill = await loadSkill(active.name);
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() || "";
auto = mod?.auto === true;
kind = mod?.kind;
pendingModule = mod ?? null;
}
} catch {
/* catalog optional */
const catalog = await this.loadActiveModuleCatalog();
const ready = listReadySteps(flow, accepted);
const autoReady = ready.filter((step) => {
const mod = findModuleByName(catalog, step.name);
return (
mod?.auto === true &&
missingRequiredStepParams(step, mod).length === 0
);
});
if (autoReady.length > 0 && autoReady.length === ready.length) {
const protoReady = listSelectablePrototypes(flow, accepted).length > 0;
const spawnReady = catalog
? listRepeatableSpawns(flow, catalog, accepted).some((s) => s.ready)
: false;
if (!protoReady && !spawnReady) {
debugLog("step", `程序步直接开始 ${autoReady[0]!.id}`);
await this.startCreationStep(autoReady[0]!.id);
return;
}
}
debugLog(
"step",
hasSelectableCreationWork(flow, catalog, accepted)
? "等待点选创作节点"
: "图上暂无已就绪节点,点节点进入或在底栏写意见改编排",
);
await this.enterCreationStepPickWait();
}
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
const params = pending.params ? { ...pending.params } : {};
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
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 revise = isReviseStep(pending);
const reason = [
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${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,
...(kind ? { kind } : {}),
...(revise ? { mode: "revise" as const } : {}),
...(pending.revises ? { revises: pending.revises } : {}),
private async enterCreationStepPickWait(): Promise<void> {
this.clearProposedStep();
if (this.session.waitingReason?.kind === "pick_creation_step") return;
if (this.session.phase === "running") {
await this.dispatch({ type: "creation_step_pick_awaited", payload: {} });
return;
}
this.session = {
...this.session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
pendingDecision: undefined,
currentWorkerId: undefined,
updatedAt: new Date().toISOString(),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
}
private clearProposedStep(): void {
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
[CREATION_PROPOSED_STEP_TAG]: undefined,
},
};
const decision = createDecision({
action: "run_worker",
workerId: "design-step",
reason,
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
requiresApproval: !(auto && paramsMissing.length === 0),
});
await this.dispatch({
type: "main_agent_decision_created",
payload: { decision },
});
}
/**
@@ -1108,6 +1548,24 @@ export class PhaseRuntime {
};
}
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
private unsealCreationOpening(): void {
const reopened = reopenCreationFlowRaw(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
if (reopened) {
this.blackboard.write({
tag: CREATION_FLOW_TAG,
content: reopened,
source: "runtime",
});
}
const slots = { ...this.session.slots };
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
if (reopened) slots[CREATION_FLOW_TAG] = reopened;
this.session = { ...this.session, slots };
}
private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
@@ -1807,6 +2265,10 @@ export class PhaseRuntime {
flowRaw,
currentStepName,
acceptedStepNames: accepted,
inheritExisting: isInheritExistingFlag(
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
),
});
if (!binding?.opening?.trim()) return false;
@@ -1952,7 +2414,9 @@ export class PhaseRuntime {
const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
);
const pending = nextPendingStep(flow, accepted);
const pinnedId = this.readPinnedStepId();
const pinned = flow && pinnedId ? findStepByRef(flow, pinnedId) : null;
const pending = pinned ?? nextPendingStep(flow, accepted);
current = pending ? stepUnitId(pending) : current || "(无待执行步骤)";
this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG,
@@ -2209,65 +2673,100 @@ export class PhaseRuntime {
}
}
private hasCreationFlow(): boolean {
return Boolean(
parseCreationFlow(this.blackboard.getContentByTag(CREATION_FLOW_TAG)),
);
}
private async commitMainAgentDecision(
decision: MainAgentDecision,
): Promise<void> {
debugLog(
"step",
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = this.commitEvent({
type: "main_agent_decision_created",
payload: { decision },
});
if (result.error) this.onMessage(result.error);
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
}
private async runMainAgent(): Promise<void> {
if (!this.mainAgent || this.session.phase !== "running") {
return;
}
if (this.hasCreationFlow()) {
debugLog("step", "已有创作流程,等待点选节点");
await this.awaitCreationStepPick();
return;
}
debugLog("step", "总管开始");
const availableWorkers = await this.resolveAvailableWorkers();
const decision = await this.mainAgent.runToolLoop(
{
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers,
},
{
handlers: {
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
listWorkers: () => availableWorkers,
listArtifacts: () =>
this.session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
onToolCall: (name, detail) => {
const preview =
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
},
onThinkingDelta: (delta) => {
this.onAgentThinkingDelta?.(delta);
},
onThinkingDone: (text) => {
this.onAgentThinkingDone?.(text);
this.onMessage(`[总管 思考]\n\n${text}`);
try {
const decision = await this.mainAgent.runToolLoop(
{
session: this.session,
blackboardIndex: this.blackboard.listTagIndex(),
availableWorkers,
},
{
handlers: {
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
listWorkers: () => availableWorkers,
listArtifacts: () =>
this.session.artifacts.map((a) => ({
id: a.id,
workerId: a.workerId,
status: a.status,
summary: a.summary,
outputTags: a.outputTags,
})),
onToolCall: (name, detail) => {
const preview =
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
},
onThinkingDelta: (delta) => {
this.onAgentThinkingDelta?.(delta);
},
onThinkingDone: (text) => {
this.onAgentThinkingDone?.(text);
this.onMessage(`[总管 思考]\n\n${text}`);
},
},
},
},
);
debugLog(
"step",
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
);
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
const result = this.commitEvent({
type: "main_agent_decision_created",
payload: { decision },
});
if (result.error) {
this.onMessage(result.error);
);
await this.commitMainAgentDecision(decision);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (this.hasCreationFlow()) {
debugLog("step", "总管无可用决策,改为等待点选");
await this.awaitCreationStepPick();
return;
}
if (isUnusableMainAgentOutput(err)) {
debugLog("step", "总管未给出工具调用,改为启动 design-flow");
await this.commitMainAgentDecision(
createDecision({
action: "run_worker",
workerId: DESIGN_FLOW_WORKER_ID,
reason: "尚无创作流程,开始编排近期起点",
requiresApproval: false,
}),
);
return;
}
debugLog("step", `总管失败 ${reason.slice(0, 120)}`);
await this.failRun(reason);
}
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
}
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
+17
View File
@@ -435,6 +435,15 @@ export function buildFocus(
};
}
if (reason?.kind === "pick_creation_step") {
return {
actorType: "user",
actorLabel: "你",
action: "点选要做的节点",
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
};
}
if (reason?.kind === "next_intent") {
return {
actorType: "user",
@@ -471,6 +480,14 @@ export function buildFocus(
if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
if (art?.workerId === "design-flow") {
return {
actorType: "user",
actorLabel: "你",
action: "点选要做的节点",
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
};
}
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
outputTags: art?.outputTags,
+13 -2
View File
@@ -123,6 +123,17 @@ export function formatAgentDisplayTitle(detail?: string): string {
return "编排器";
}
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
export function isFlowPlanReview(view: {
waitingReason?: { kind?: string } | null;
reviewArtifact?: { workerId?: string | null } | null;
} | null | undefined): boolean {
return (
view?.waitingReason?.kind === "review_artifact" &&
view?.reviewArtifact?.workerId === "design-flow"
);
}
/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
export type ReviewComposerCopy = {
/** 底栏短芯片 */
@@ -179,8 +190,8 @@ export function reviewComposerCopy(
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
: "同意就选定此开场;要改开场白就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
+324 -32
View File
@@ -81,8 +81,10 @@ import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
isDesignDiskWorker,
parseAcceptedContentStore,
parseAcceptedUnits,
SLOT_CREATION_ACCEPTED_UNITS,
} from "../skills/creation-units.js";
@@ -104,18 +106,26 @@ import {
CREATION_FLOW_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG,
DESIGN_FLOW_WORKER_ID,
creationFlowFromRecipeSeed,
findRecipeCatalogEntry,
formatCreationFlowForUser,
findModuleByName,
findStepByRef,
type FlowStepTitleHint,
isProgressPointerTag,
loadRecipeCatalog,
loadRecipeDetail,
loadModuleCatalog,
parseCreationFlow,
parseSelectedRecipeRef,
resolveDesignStepBinding,
stepUnitId,
stringifyCreationFlow,
stripReviseSteps,
type CreationFlow,
type CreationFlowUserView,
type ModuleCatalog,
type RecipeCatalogEntry,
} from "../skills/creation-flow.js";
import { loadSkill } from "../skills/loader.js";
@@ -260,6 +270,8 @@ export type SessionAction =
| { type: "approve"; label: string }
| { type: "accept"; label: string }
| { type: "reject"; label: string }
| { type: "replan"; label: string }
| { type: "leave_step"; label: string }
| { type: "run_outline"; label: string }
| { type: "finish"; label: string };
@@ -297,6 +309,8 @@ export type ProposedNextStepView = {
/** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise";
revises?: string;
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
closer?: boolean;
};
export type ReviewArtifactView = {
@@ -325,6 +339,8 @@ type ManagedSession = {
branchState: MessageBranchState;
/** 当前能力包可选初始配方(用户手动选) */
recipeOptions?: RecipeCatalogEntry[];
/** 模块目录(分层图 / 可反复增殖) */
moduleCatalog?: ModuleCatalog | null;
/** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */
@@ -540,6 +556,7 @@ export class SessionManager {
if (bookId) {
this.activeBookSessions.set(bookId, id);
appendBookSession(bookId, id);
this.persist(managed);
}
return this.toView(id);
}
@@ -557,7 +574,9 @@ export class SessionManager {
}
get(id: string): SessionView | null {
if (!this.sessions.has(id)) return null;
const s = this.sessions.get(id);
if (!s) return null;
if (s.runtime.recoverOrphanedRun()) this.persist(s);
return this.toView(id);
}
@@ -825,7 +844,8 @@ export class SessionManager {
kind !== "review_artifact" &&
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent"
kind !== "next_intent" &&
kind !== "pick_creation_step"
) {
return;
}
@@ -1079,8 +1099,7 @@ export class SessionManager {
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 发送消息失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
await this.recoverFailedRun(s, detail);
}
this.syncCreationDialogue(s);
this.persist(s);
@@ -1315,8 +1334,7 @@ export class SessionManager {
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止并重试失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
await this.recoverFailedRun(s, detail);
}
this.syncCreationDialogue(s);
this.persist(s);
@@ -1462,7 +1480,7 @@ export class SessionManager {
try {
const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行");
await s.runtime.leaveCreationStep();
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
@@ -1476,9 +1494,174 @@ export class SessionManager {
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 离开当前技能步,回到分层图点选 */
async leaveStep(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.leaveCreationStep(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 返回节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
async replan(id: string, reason?: string): Promise<SessionView> {
const s = this.require(id);
const note = reason?.trim() || "回到流程编排,追加或改排节点。";
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(this.msg("user", note));
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.replanCreationFlow(note),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 再编排失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
async pickStep(
id: string,
stepId: string,
stepParams?: Record<string, unknown>,
reenter?: boolean,
): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.pickCreationStep(stepId, stepParams, {
reenter: reenter === true,
});
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 点选节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
async getStepArtifact(
id: string,
stepId: string,
): Promise<{
stepId: string;
name: string;
tag: string | null;
content: string;
}> {
const s = this.require(id);
const catalog = s.moduleCatalog;
const flow = parseCreationFlow(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) throw new Error("还没有工作流计划");
const cleaned = stripReviseSteps(flow);
const step = findStepByRef(cleaned, stepId.trim());
if (!step) throw new Error(`找不到节点:${stepId}`);
const mod = findModuleByName(catalog, step.name);
const tag = mod?.artifact?.trim() || null;
const content = tag
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
: "";
return { stepId: step.id, name: step.name, tag, content };
}
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是追加节点的时机");
}
await s.runtime.spawnCreationStep(moduleName);
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 追加节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
private canOfferLeaveCreationStep(
s: ManagedSession,
reason: WaitingReason | undefined,
): boolean {
const session = s.runtime.getSession();
if (
isPlayLayerActive(session.slots) ||
inferLifecycleStage(session) === "play"
) {
return false;
}
const kind = reason?.kind;
if (
kind !== "worker_questions" &&
kind !== "input" &&
kind !== "revision"
) {
return false;
}
const stepId =
s.runtime.getBlackboard().getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() ||
session.currentStepId?.trim() ||
"";
return Boolean(stepId);
}
/** 流程编排还停在核对态时,点节点先收下计划再进入 */
private async confirmPendingFlowPlan(s: ManagedSession): Promise<void> {
const session = s.runtime.getSession();
if (session.waitingReason?.kind !== "review_artifact") return;
const art = session.pendingArtifactId
? session.artifacts.find((a) => a.id === session.pendingArtifactId)
: undefined;
if (art?.workerId !== DESIGN_FLOW_WORKER_ID) {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.acceptArtifact();
}
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
try {
@@ -1586,6 +1769,7 @@ export class SessionManager {
this.activeBookSessions.set(params.bookId, params.sessionId);
appendBookSession(params.bookId, params.sessionId);
await this.refreshRecipeOptions(managedRef);
runtime.recoverOrphanedRun();
this.persist(managedRef);
await this.maybeKickAgentAfterDemand(runtime);
@@ -1891,6 +2075,20 @@ export class SessionManager {
}
}
/** 执行失败后离开 running,避免界面一直转圈。 */
private async recoverFailedRun(
s: ManagedSession,
detail: string,
): Promise<void> {
const formatted = formatRuntimeError(detail);
if (s.runtime.getSession().phase === "running") {
await s.runtime.failRun(formatted);
} else {
s.messages.push(this.msg("system", formatted));
}
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
@@ -2202,11 +2400,21 @@ export class SessionManager {
} else {
actions.push({ type: "approve", label: "确认执行" });
}
actions.push({ type: "reject", label: "暂不执行" });
actions.push({ type: "reject", label: "返回节点" });
actions.push({
type: "send_message",
label: "说明意见",
placeholder: "改排、回头改某步,或说明不要这一步…",
placeholder: "改排、补节点(如 NPC)、回头改某步,或说明不要这一步…",
});
} else if (reason?.kind === "pick_creation_step") {
hints.push(
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({ type: "replan", label: "发送" });
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
@@ -2220,15 +2428,30 @@ export class SessionManager {
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 (art?.workerId === DESIGN_FLOW_WORKER_ID) {
hints.push(
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else {
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) {
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
hints.push(
art?.workerId === DESIGN_FLOW_WORKER_ID
? "下方追问可选;点节点进入即收起"
: `下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`,
);
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
}
@@ -2281,6 +2504,10 @@ export class SessionManager {
});
}
if (this.canOfferLeaveCreationStep(s, reason)) {
actions.push({ type: "leave_step", label: "返回节点" });
}
if (s.bookId) {
this.persist(s);
}
@@ -2330,7 +2557,12 @@ export class SessionManager {
effectiveStartupMode(activeSnap) === "agent-first"
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
: undefined,
reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()),
reviewArtifact: buildReviewArtifactView(
session,
messages,
s.runtime.getBlackboard(),
s.moduleCatalog,
),
workerSetView: buildWorkerSetUserView(
s.runtime.getBlackboard(),
s.runtime.getSession(),
@@ -2338,6 +2570,7 @@ export class SessionManager {
creationFlowView: buildCreationFlowUserView(
s.runtime.getBlackboard(),
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
s.moduleCatalog,
),
proposedNextStep:
reason?.kind === "approve_step"
@@ -2448,6 +2681,7 @@ export class SessionManager {
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
@@ -2455,13 +2689,19 @@ export class SessionManager {
const skill = await loadSkill(skillName);
if (!skill.skillPackRoot) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
const [catalog, modules] = await Promise.all([
loadRecipeCatalog(skill.skillPackRoot),
loadModuleCatalog(skill.skillPackRoot),
]);
s.recipeOptions = catalog?.recipes ?? [];
s.moduleCatalog = modules;
} catch {
s.recipeOptions = [];
s.moduleCatalog = null;
}
await this.ensureRecipeSeededFlow(s);
await this.refreshOpeningGuide(s);
@@ -2489,11 +2729,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (!seeded) return;
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
await this.commitSeededCreationFlow(s, seeded);
} catch (err) {
console.error("[会话] 配方预置失败", err);
}
@@ -2553,6 +2789,7 @@ export class SessionManager {
s: ManagedSession,
reason: WaitingReason | undefined,
): { stepId: string; stepName: string; text: string } | null {
if (reason?.kind === "pick_creation_step") return null;
if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
@@ -2584,6 +2821,44 @@ export class SessionManager {
return s.openingGuide ?? null;
}
/** 写入剧本 seed,并立刻进入点选图(开局节点跟配方走)。 */
private async commitSeededCreationFlow(
s: ManagedSession,
seeded: CreationFlow,
): Promise<void> {
this.writeSeededCreationFlow(s, seeded);
await s.runtime.enterSeededCreationPick();
}
/** 配方预置 DAG:写入黑板并留下可回退的编排检查点(不必先跑 LLM) */
private writeSeededCreationFlow(s: ManagedSession, seeded: CreationFlow): void {
s.runtime.getBlackboard().write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
const alreadyRecorded = s.messages.some(
(m) =>
m.kind === "worker_output" &&
m.actor === "design-flow" &&
(m.text ?? "").includes("配方预置近期起点"),
);
if (alreadyRecorded) return;
const names = seeded.steps.map((step) => step.name).join(" → ");
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(
this.msg(
"system",
`[Worker] design-flow 已完成\n\n配方预置近期起点${names ? `:${names}` : ""}。\n这是可回退的编排检查点;之后可再编排追加节点(如生成规则 / 具体实例)。`,
),
);
this.syncCreationDialogue(s);
}
private async applySelectedRecipe(
s: ManagedSession,
recipeId: string,
@@ -2606,7 +2881,7 @@ export class SessionManager {
source: "user",
});
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow)
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!existingFlow) {
const skillName = s.runtime.getActiveSkill()?.name;
@@ -2618,11 +2893,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (seeded) {
board.write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
await this.commitSeededCreationFlow(s, seeded);
}
}
} catch (err) {
@@ -2652,6 +2923,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
function buildCreationFlowUserView(
blackboard: Blackboard,
acceptedRaw?: unknown,
catalog?: ModuleCatalog | null,
): CreationFlowUserView | undefined {
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!raw) return undefined;
@@ -2662,7 +2934,24 @@ function buildCreationFlowUserView(
const accepted = parseAcceptedUnits(
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
return formatCreationFlowForUser(flow, undefined, accepted);
const titleHints: Record<string, FlowStepTitleHint> = {};
const store = parseAcceptedContentStore(
blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
);
for (const [id, entry] of Object.entries(store.units)) {
if (!entry.summary && entry.content == null) continue;
titleHints[id] = {
...(entry.summary ? { summary: entry.summary } : {}),
...(entry.content != null ? { content: entry.content } : {}),
};
}
return formatCreationFlowForUser(
flow,
catalog,
accepted,
collectFilledTags(blackboard),
titleHints,
);
}
function readProposedNextStep(
@@ -2708,6 +2997,7 @@ function readProposedNextStep(
intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined,
closer: doc.closer === true ? true : undefined,
};
} catch {
return undefined;
@@ -2767,6 +3057,7 @@ function buildReviewArtifactView(
session: RuntimeSession,
messages: ChatMessage[],
blackboard: Blackboard,
catalog?: ModuleCatalog | null,
): ReviewArtifactView | undefined {
if (session.waitingReason?.kind !== "review_artifact") return undefined;
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
@@ -2869,6 +3160,7 @@ function buildReviewArtifactView(
? buildCreationFlowUserView(
blackboard,
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
catalog,
)
: undefined;
@@ -2896,7 +3188,7 @@ function formatRuntimeError(detail: string): string {
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
return (
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
"或点击「测试连接」验证。"
"或点击「探测」验证。"
);
}
if (detail.startsWith("LLM request failed")) {
+38 -11
View File
@@ -4,9 +4,13 @@ import {
deleteApiProfile,
getApiProfile,
listApiProfiles,
testApiProfile,
probeAndSaveProfileCapabilities,
updateApiProfile,
} from "../config/api-profiles.js";
import {
deliveryModeLabel,
resolveStructuredDeliveryMode,
} from "../llm/capabilities.js";
import {
ensureActiveProfileDefault,
loadAppSettings,
@@ -120,12 +124,14 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
reasoningEffort?: string;
};
const profile = createApiProfile({
name: body.name ?? "新配置",
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
apiKey: body.apiKey ?? "",
model: body.model ?? "deepseek-v4-pro",
reasoningEffort: body.reasoningEffort,
});
const activate = (body as { activate?: boolean }).activate === true;
if (activate) {
@@ -136,19 +142,34 @@ export async function handleSettingsApi(
return true;
}
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
const profileMatch = pathname.match(
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
);
if (profileMatch) {
const id = decodeURIComponent(profileMatch[1]);
const isTest = profileMatch[2] === "/test";
const suffix = profileMatch[2];
if (isTest && req.method === "POST") {
const profile = getApiProfile(id);
if (!profile) {
json(res, 404, { error: "配置不存在" });
return true;
if (suffix === "/probe-capabilities" && req.method === "POST") {
try {
const profile = await probeAndSaveProfileCapabilities(id);
const mode = resolveStructuredDeliveryMode(profile.capabilities);
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, {
ok: profile.capabilities?.chat === "ok",
profile,
deliveryMode: mode,
deliveryModeLabel: deliveryModeLabel(mode),
reloadedSessions,
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "能力探测失败",
});
}
const result = await testApiProfile(profile);
json(res, 200, result);
return true;
}
@@ -168,10 +189,16 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
reasoningEffort?: string;
};
try {
const profile = updateApiProfile(id, body);
json(res, 200, { profile });
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, { profile, reloadedSessions });
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
+41
View File
@@ -123,6 +123,18 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "GET" && sub === "/step-artifact") {
try {
const stepId = url.searchParams.get("stepId") ?? "";
const data = await sessionManager.getStepArtifact(sessionId, stepId);
json(res, 200, data);
} catch (err) {
const msg = err instanceof Error ? err.message : "读取失败";
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
}
return;
}
if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as {
text?: string;
@@ -445,6 +457,10 @@ const server = createServer(async (req, res) => {
action?: string;
stepParams?: Record<string, unknown>;
openingIndex?: number;
reason?: string;
stepId?: string;
moduleName?: string;
reenter?: boolean;
};
let view;
try {
@@ -474,6 +490,31 @@ const server = createServer(async (req, res) => {
case "reject":
view = await sessionManager.reject(sessionId);
break;
case "replan":
view = await sessionManager.replan(
sessionId,
typeof body.reason === "string" ? body.reason : undefined,
);
break;
case "leave_step":
view = await sessionManager.leaveStep(sessionId);
break;
case "pick_step":
view = await sessionManager.pickStep(
sessionId,
typeof body.stepId === "string" ? body.stepId : "",
body.stepParams && typeof body.stepParams === "object"
? body.stepParams
: undefined,
body.reenter === true,
);
break;
case "spawn_step":
view = await sessionManager.spawnStep(
sessionId,
typeof body.moduleName === "string" ? body.moduleName : "",
);
break;
case "run_outline":
view = await sessionManager.runOutline(sessionId);
break;
+7 -17
View File
@@ -2,7 +2,7 @@
* 上下文片段产物(context-fragment.v1)解析与展示辅助。
* 规范:docs/context-fragment-design.md
*/
import { extractJsonObjectText } from "./worker-set-parse.js";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import {
normalizeQuestions,
type QuestionItem,
@@ -75,14 +75,9 @@ function normalizeStability(v: unknown): ContextFragmentStability | undefined {
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return undefined;
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
@@ -278,14 +273,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
} {
let row: Record<string, unknown> | null = null;
if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim();
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} catch {
return { questions: [], assessment: "" };
const parsed = tryParseJsonDoc(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>;
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -44,7 +44,8 @@ type WorkerTemplateDoc = {
/**
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - design-flow → no_confirmation(图上点节点即确认;底栏输入即改编排)
* - design-step / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:创作默认确认
@@ -55,6 +56,7 @@ export function resolveAcceptanceModeForWorker(params: {
workerId: string;
}): AcceptanceMode {
const workerId = params.workerId.trim();
if (workerId === "design-flow") return "no_confirmation";
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed";
}
@@ -95,7 +97,7 @@ export async function resolveRunnableWorker(params: {
const acceptedRaw =
params.session.slots?.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
const { parseAcceptedSteps } = await import("./creation-flow.js");
const { parseAcceptedSteps, isInheritExistingFlag } = await import("./creation-flow.js");
const withCtx = await loadWorkerSkillWithContext(
params.skillPackName,
params.workerId,
@@ -106,6 +108,9 @@ export async function resolveRunnableWorker(params: {
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef,
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
inheritExisting: isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
),
},
);
return {
+258
View File
@@ -0,0 +1,258 @@
/**
* 扁平产物契约(wire format):机器字段薄,正文用 sections[] 字符串块。
* 渲染与旧 context-fragment.v1 深树通过 normalize 互转。
*/
export const FLAT_ARTIFACT_SCHEMA_ID = "artifact.flat.v1" as const;
export type FlatArtifactQuestion = {
id: string;
prompt: string;
options: string[];
};
export type FlatArtifactSection = {
id: string;
title: string;
text: string;
};
/** 所有需程序校验的创作产物尽量收成此形 */
export type FlatArtifact = {
schema: typeof FLAT_ARTIFACT_SCHEMA_ID | string;
skill: string;
brief: string;
mount: string[];
stability: "stable" | "semi" | "volatile" | "";
sections: FlatArtifactSection[];
open_questions: string[];
questions: FlatArtifactQuestion[];
summary: string;
};
/** OpenAI/DeepSeek strict 友好的 JSON Schema(全 required + additionalProperties:false) */
export const FLAT_ARTIFACT_JSON_SCHEMA: Record<string, unknown> = {
type: "object",
additionalProperties: false,
required: [
"schema",
"skill",
"brief",
"mount",
"stability",
"sections",
"open_questions",
"questions",
"summary",
],
properties: {
schema: { type: "string" },
skill: { type: "string" },
brief: { type: "string" },
mount: { type: "array", items: { type: "string" } },
stability: {
type: "string",
enum: ["stable", "semi", "volatile", ""],
},
sections: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "title", "text"],
properties: {
id: { type: "string" },
title: { type: "string" },
text: { type: "string" },
},
},
},
open_questions: { type: "array", items: { type: "string" } },
questions: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "prompt", "options"],
properties: {
id: { type: "string" },
prompt: { type: "string" },
options: { type: "array", items: { type: "string" } },
},
},
},
summary: { type: "string" },
},
};
export function isFlatArtifact(raw: unknown): raw is FlatArtifact {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
const row = raw as Record<string, unknown>;
return Array.isArray(row.sections);
}
/** 扁形 → 旧 fragment 外壳,便于现有渲染;正文按 section id 堆对象。
* section.text 若是 JSON 对象/数组字符串,解析回结构,避免专用卡吃到整段 stringify。
*/
export function flatArtifactToFragmentDoc(flat: FlatArtifact): Record<string, unknown> {
const 正文: Record<string, unknown> = {};
for (const s of flat.sections) {
const key = s.id || s.title;
if (!key) continue;
正文[key] = coerceSectionText(s.text);
}
return {
schema: "context-fragment.v1",
技能: flat.skill,
brief: flat.brief,
mount: flat.mount,
...(flat.stability ? { 稳变: flat.stability } : {}),
正文,
开放问题: flat.open_questions,
追问: {
导语: "",
题目: flat.questions.map((q) => ({
问: q.prompt,
建议选项: q.options,
})),
},
...(flat.summary ? { summary: flat.summary } : {}),
};
}
/** section 文本:纯散文保留;JSON 对象/数组还原为结构 */
export function coerceSectionText(text: string): unknown {
const raw = String(text ?? "").trim();
if (!raw) return "";
if (
!(
(raw.startsWith("{") && raw.endsWith("}")) ||
(raw.startsWith("[") && raw.endsWith("]"))
)
) {
return String(text ?? "");
}
try {
return JSON.parse(raw) as unknown;
} catch {
return String(text ?? "");
}
}
/** 旧深树 / 任意对象 → 尽量收成扁形(保存与投递归一) */
export function coerceToFlatArtifact(raw: unknown): FlatArtifact | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const row = raw as Record<string, unknown>;
// DAG / worker 集 / 上下文排序:不是扁形正文稿,不能靠 brief 误收
if (
Array.isArray(row.steps) ||
Array.isArray(row.workers) ||
Array.isArray(row.agents) ||
Array.isArray(row.slots)
) {
return null;
}
if (isFlatArtifact(raw)) {
return {
schema:
typeof row.schema === "string" ? row.schema : FLAT_ARTIFACT_SCHEMA_ID,
skill: String(row.skill ?? row.技能 ?? ""),
brief: String(row.brief ?? ""),
mount: Array.isArray(row.mount)
? row.mount.filter((x): x is string => typeof x === "string")
: [],
stability:
row.stability === "stable" ||
row.stability === "semi" ||
row.stability === "volatile"
? row.stability
: row.稳变 === "stable" ||
row.稳变 === "semi" ||
row.稳变 === "volatile"
? row.稳变
: "",
sections: (row.sections as FlatArtifactSection[]).map((s) => ({
id: String(s?.id ?? ""),
title: String(s?.title ?? ""),
text: String(s?.text ?? ""),
})),
open_questions: Array.isArray(row.open_questions)
? row.open_questions.filter((x): x is string => typeof x === "string")
: Array.isArray(row.开放问题)
? row.开放问题.filter((x): x is string => typeof x === "string")
: [],
questions: Array.isArray(row.questions)
? (row.questions as FlatArtifactQuestion[]).map((q, i) => ({
id: String(q?.id ?? `q${i + 1}`),
prompt: String(q?.prompt ?? ""),
options: Array.isArray(q?.options)
? q.options.filter((x): x is string => typeof x === "string")
: [],
}))
: [],
summary: String(row.summary ?? ""),
};
}
const 正文 = row.正文 ?? row.body;
const sections: FlatArtifactSection[] = [];
if (正文 && typeof 正文 === "object" && !Array.isArray(正文)) {
for (const [key, val] of Object.entries(正文 as Record<string, unknown>)) {
sections.push({
id: key,
title: key,
text:
typeof val === "string"
? val
: JSON.stringify(val, null, 2),
});
}
} else if (typeof 正文 === "string" && 正文.trim()) {
sections.push({ id: "body", title: "正文", text: 正文 });
}
const probe = row.追问;
const questions: FlatArtifactQuestion[] = [];
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
const topics = (probe as { 题目?: unknown }).题目;
if (Array.isArray(topics)) {
topics.forEach((t, i) => {
if (!t || typeof t !== "object") return;
const item = t as Record<string, unknown>;
questions.push({
id: `q${i + 1}`,
prompt: String(item.问 ?? item.prompt ?? ""),
options: Array.isArray(item.建议选项)
? item.建议选项.filter((x): x is string => typeof x === "string")
: Array.isArray(item.options)
? item.options.filter((x): x is string => typeof x === "string")
: [],
});
});
}
}
if (!sections.length) return null;
const stab = row.稳变 ?? row.stability;
return {
schema: FLAT_ARTIFACT_SCHEMA_ID,
skill: String(row.技能 ?? row.skill ?? ""),
brief: String(row.brief ?? ""),
mount: Array.isArray(row.mount)
? row.mount.filter((x): x is string => typeof x === "string")
: [],
stability:
stab === "stable" || stab === "semi" || stab === "volatile" ? stab : "",
sections,
open_questions: Array.isArray(row.开放问题)
? row.开放问题.filter((x): x is string => typeof x === "string")
: Array.isArray(row.open_questions)
? row.open_questions.filter((x): x is string => typeof x === "string")
: [],
questions,
summary: String(row.summary ?? ""),
};
}
+8 -2
View File
@@ -475,6 +475,8 @@ export async function loadWorkerSkillWithContext(
selectedRecipeRef?: string | null;
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
filledArtifactTags?: readonly string[];
/** 用户点已完成节点重进 */
inheritExisting?: boolean;
},
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
@@ -526,6 +528,7 @@ export async function loadWorkerSkillWithContext(
currentStepName: opts?.currentStepName,
acceptedStepNames: opts?.acceptedStepNames,
skillsRoot,
inheritExisting: opts?.inheritExisting,
});
if (binding) {
const inheritTag = binding.inheritTag?.trim() || "";
@@ -536,8 +539,11 @@ export async function loadWorkerSkillWithContext(
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: "";
const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
? formatPriorArtifactContext(
binding.step.params,
binding.prototypeSuggestion,
)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问编排层已定的参数)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = "";
const depProjection =
binding.module.id === "context-order" ? "index" : undefined;
+8
View File
@@ -198,3 +198,11 @@ export function closeCreationFlowRaw(raw: string | null | undefined): string | n
if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" });
}
/** 再编排:收口前补节点时把 DAG 改回 open。 */
export function reopenCreationFlowRaw(raw: string | null | undefined): string | null {
const flow = parseCreationFlow(raw);
if (!flow) return raw?.trim() ? raw : null;
if (flow.status !== "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "open" });
}
+5
View File
@@ -1,4 +1,5 @@
import { parse as parseYaml } from "yaml";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import {
inferPlaySlotsFromWorkers,
mergeWorkersWithPlaySlots,
@@ -400,6 +401,10 @@ export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | n
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
export function extractJsonObjectText(raw: string): string | null {
const parsed = tryParseJsonDoc(raw);
if (parsed != null && typeof parsed === "object") {
return JSON.stringify(parsed);
}
const text = raw.trim();
if (!text) return null;
if (text.startsWith("{")) {
+14 -4
View File
@@ -72,9 +72,16 @@ export function buildContextTrace(params: {
};
}
/** 编排检查点:不随「只留最新 N 条」丢掉,否则开场白阶段无法回看/回退到流程编排 */
export function isPinnedContextTraceCaller(caller: string | undefined): boolean {
const id = (caller ?? "").trim();
return id === "worker:design-flow" || id.endsWith(":design-flow");
}
/**
* 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文;
* 更早的消息删除 contextTrace 字段(消息本身保留)。
* design-flow 痕迹钉住不删(keep=0 仍全部清除)。
*/
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
messages: T[],
@@ -88,12 +95,15 @@ export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>
return rest as T;
});
}
const withTraceIdx: number[] = [];
const droppableIdx: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].contextTrace) withTraceIdx.push(i);
const trace = messages[i].contextTrace;
if (!trace) continue;
if (isPinnedContextTraceCaller(trace.caller)) continue;
droppableIdx.push(i);
}
if (withTraceIdx.length <= keep) return messages;
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep));
if (droppableIdx.length <= keep) return messages;
const drop = new Set(droppableIdx.slice(0, droppableIdx.length - keep));
return messages.map((m, i) => {
if (!drop.has(i) || !m.contextTrace) return m;
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
+30 -1
View File
@@ -60,6 +60,13 @@ export type WaitingReason =
kind: "next_intent";
afterWorkerId?: string;
afterUnitId?: string;
}
| {
/**
* DAG 已在:按依赖层级点选节点。依赖未齐的不能点;
* 可反复能力以 role=prototype 槽位呈现;点原型增殖 instance 再进去。
*/
kind: "pick_creation_step";
};
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
@@ -232,6 +239,26 @@ export type RuntimeEvent =
type: "user_rejected_next_step";
payload: { decisionId: string; reason?: string };
}
| {
/** 回到流程编排:在已验收步骤上追加/改排节点(不整局重开) */
type: "user_requested_flow_replan";
payload: { reason?: string };
}
| {
/** DAG 图上点选一条已就绪的步骤,进入 design-step */
type: "user_picked_creation_step";
payload: { stepId: string };
}
| {
/** 验收后 / 总管误调 design-step:停在分层图上等用户点选 */
type: "creation_step_pick_awaited";
payload?: Record<string, never>;
}
| {
/** 离开当前技能步,回到分层图点选(误点增殖节点时用) */
type: "user_left_creation_step";
payload?: Record<string, never>;
}
| {
type: "worker_started";
payload: {
@@ -331,10 +358,12 @@ export type PhaseEffect =
| { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
/** 根据流程与用户下一步意向,提案下一节点或停在分层图上等点选 */
| { type: "propose_next_creation_step" }
/** 选定开场白:落库、关 DAG、标记创作收口 */
| { type: "seal_creation_opening" }
/** 再编排:解开开场收口、把 DAG 改回 open */
| { type: "unseal_creation_opening" }
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
| { type: "run_play_turn" }
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
+126 -63
View File
@@ -1,6 +1,7 @@
import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js";
import { supportsContentStream } from "../llm/stream-complete.js";
import { completeStructured } from "../llm/structured-complete.js";
import { resolveActiveProfile } from "../config/settings.js";
import { loadWorkerSkillWithContext } from "../skills/loader.js";
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
import { collectUserInputTranscript } from "../intake/intake.js";
@@ -14,6 +15,10 @@ import {
looksLikeProseNotSpec,
parseWorkerSetYaml,
} from "../skills/worker-set-parse.js";
import {
selectJsonPayload,
tryParseJsonDoc,
} from "../parse/json-doc.js";
import { assembleWorkerContext } from "../skills/context-segments.js";
import { isPlayLayerActive } from "../skills/play-turn.js";
import { loadAppSettings } from "../config/settings.js";
@@ -21,6 +26,10 @@ import { resolveActivePreset } from "../preset/store.js";
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
import { PRESENT_TAG } from "../skills/present-packet.js";
import {
coerceToFlatArtifact,
flatArtifactToFragmentDoc,
} from "../skills/flat-artifact.js";
import {
gmChanceToolsEnabled,
GM_CHANCE_HARNESS_INSTRUCTION,
@@ -36,7 +45,10 @@ import {
isUsableContextFragment,
looksLikeFragmentDoc,
} from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js";
import {
isProgressPointerTag,
looksLikeCreationFlowDoc,
} from "../skills/creation-flow.js";
import {
CONTEXT_ORDER_TAG,
parseContextOrder,
@@ -93,28 +105,19 @@ const WORKER_OUTPUT_INSTRUCTION = `
---
## 运行时输出协议(必须遵守)
## 运行时输出协议
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 问题数组
}
输出 **一个 JSON 对象**(由程序按模型能力用 schema / tool / json_object 约束投递)。
askUser 每项可为:
- 字符串:"需要用户补充的问题"
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
按本步 SKILL 规定的产物形状写。有正文的技能稿按该步 \`正文\` 对象写(柱下到条目/诊断三元组),不要把业务字段改成一段 \`sections[].text\`。
「设计.创作流程」必须是带 \`steps\` 数组的 DAG,worker 集必须是带 \`workers\` 的规格。
- outputs 的 key 必须是要求的 outputTags
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
也兼容:
1. 直接输出本步产物对象(创作流程 / context-fragment.v1 / worker 集等)
2. \`{ "outputs": { "<本步 tag>": { … } }, "summary":"…", "askUser": null }\`(values 用对象)
不要写入进度指针。追问用 \`questions\`(或旧 \`追问.题目\`);\`questions: []\` 表示可验收。
「开放问题」留给后续步。残稿也要交。summary 用于界面展示。`;
function slotValueForTag(
tag: string,
@@ -193,6 +196,13 @@ function assignOutput(
}
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
function existingOutputIsProduct(text: string | undefined): boolean {
if (!text?.trim()) return false;
if (isUsableContextFragment(text)) return true;
const parsed = tryParseJsonDoc(text);
return looksLikeCreationFlowDoc(parsed) || looksLikeFragmentDoc(parsed);
}
function recoverFragmentOutputs(
obj: Record<string, unknown>,
outputs: Record<string, string>,
@@ -200,7 +210,7 @@ function recoverFragmentOutputs(
): void {
const target = productOutputTags(outputTags)[0];
if (!target) return;
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
if (existingOutputIsProduct(outputs[target])) return;
const outputsRaw =
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
@@ -212,7 +222,11 @@ function recoverFragmentOutputs(
if (key === target) continue;
const text = stringifyOutputValue(val);
if (!text) continue;
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
if (
isUsableContextFragment(text) ||
looksLikeFragmentDoc(val) ||
looksLikeCreationFlowDoc(val)
) {
outputs[target] = text;
return;
}
@@ -227,6 +241,13 @@ function recoverFragmentOutputs(
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
const source = looksLikeFragmentDoc(obj) ? obj : rest;
outputs[target] = JSON.stringify(source);
return;
}
if (looksLikeCreationFlowDoc(obj) || looksLikeCreationFlowDoc(rest)) {
const source = looksLikeCreationFlowDoc(obj) ? obj : rest;
if (!outputs[target]?.trim()) {
outputs[target] = JSON.stringify(source);
}
}
}
@@ -283,14 +304,26 @@ const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
},
]);
function lookupOutputValue(
raw: Record<string, unknown>,
tag: string,
): unknown {
if (raw[tag] != null) return raw[tag];
const leaf = tag.includes(".") ? tag.slice(tag.lastIndexOf(".") + 1) : tag;
if (!leaf) return undefined;
for (const [key, val] of Object.entries(raw)) {
if (val == null) continue;
if (key === leaf || key.endsWith(`.${leaf}`)) return val;
}
return undefined;
}
function parseWorkerResponse(
raw: string,
outputTags: string[],
): WorkerRunResult {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
const parsed = tryParseJsonDoc(raw);
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs(
@@ -322,17 +355,43 @@ function parseWorkerResponse(
};
}
if (!parsed || typeof parsed !== "object") {
throw new Error("Worker 返回了无效 JSON");
const obj = parsed as Record<string, unknown>;
// 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉)
if (looksLikeCreationFlowDoc(obj)) {
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target) {
return finalizeParsedOutputs(
{ [target]: JSON.stringify(obj) },
obj,
outputTags,
);
}
}
// 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。
if (!looksLikeFragmentDoc(obj)) {
const asFlat = coerceToFlatArtifact(obj);
if (asFlat) {
const frag = flatArtifactToFragmentDoc(asFlat);
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target) {
return finalizeParsedOutputs(
{ [target]: JSON.stringify(frag) },
{ ...frag, summary: asFlat.summary || obj.summary },
outputTags,
);
}
}
}
const obj = parsed as Record<string, unknown>;
const outputsRaw = obj.outputs;
const outputs: Record<string, string> = {};
if (outputsRaw && typeof outputsRaw === "object") {
if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) {
const bag = outputsRaw as Record<string, unknown>;
for (const tag of productOutputTags(outputTags)) {
assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
assignOutput(outputs, tag, lookupOutputValue(bag, tag));
}
}
recoverFragmentOutputs(obj, outputs, outputTags);
@@ -484,25 +543,6 @@ function extractQuestionsFromText(text: string): string[] {
return [];
}
async function completeWorkerPreferStream(
llm: LlmProvider,
messages: Parameters<LlmProvider["complete"]>[0],
options: Parameters<LlmProvider["complete"]>[1],
stream?: WorkerStreamCallbacks,
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
const callbacks: StreamCallbacks = {
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
};
if (supportsContentStream(llm)) {
return llm.completeStream!(messages, options, callbacks);
}
const result = await llm.complete(messages, options);
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
if (result.content) callbacks.onContentDelta?.(result.content);
return result;
}
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId);
let worker: ParsedWorkerSkill;
@@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
params.slots.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位");
let acceptedStepNames: string[] = [];
let inheritExisting = false;
try {
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
const { parseAcceptedSteps, isInheritExistingFlag } = await import(
"../skills/creation-flow.js"
);
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
inheritExisting = isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
);
} catch {
acceptedStepNames = [];
}
@@ -538,6 +584,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
acceptedStepNames,
selectedRecipeRef,
filledArtifactTags,
inheritExisting,
},
);
worker = loaded.worker;
@@ -616,20 +663,36 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
stream: params.stream,
caller: `worker:${worker.id}`,
});
return parseWorkerResponse(harness.content, worker.outputTags);
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
}
const result = await completeWorkerPreferStream(
params.llm,
messages,
{
responseFormat: "json_object",
caller: `worker:${worker.id}`,
},
params.stream,
);
const streamCallbacks: StreamCallbacks | undefined = params.stream
? {
onReasoningDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
onContentDelta: (delta) => params.stream?.onOutputDelta?.(delta),
}
: undefined;
return parseWorkerResponse(result.content, worker.outputTags);
const result = await completeStructured(params.llm, messages, {
schema: {
type: "object",
additionalProperties: true,
},
name: "submit_worker_result",
schemaIsLoose: true,
capabilities: resolveActiveProfile()?.capabilities,
caller: `worker:${worker.id}`,
stream: streamCallbacks,
});
const raw =
typeof result.parsed === "string"
? result.parsed
: JSON.stringify(result.parsed);
return parseWorkerResponse(
selectJsonPayload(raw, result.reasoning) || raw,
worker.outputTags,
);
}
/** @internal 供单测 */
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import {
CAPABILITY_PROBE_REASONING_EFFORT,
resolveStructuredDeliveryMode,
type ProfileCapabilities,
} from "../src/llm/capabilities.js";
import { normalizeProfileReasoningEffort } from "../src/config/api-profiles.js";
import {
coerceToFlatArtifact,
flatArtifactToFragmentDoc,
FLAT_ARTIFACT_SCHEMA_ID,
} from "../src/skills/flat-artifact.js";
describe("capability probe defaults", () => {
it("always probes with low reasoning effort", () => {
expect(CAPABILITY_PROBE_REASONING_EFFORT).toBe("low");
});
});
describe("normalizeProfileReasoningEffort", () => {
it("keeps valid effort and drops auto/empty", () => {
expect(normalizeProfileReasoningEffort("low")).toBe("low");
expect(normalizeProfileReasoningEffort("MAX")).toBe("max");
expect(normalizeProfileReasoningEffort("auto")).toBeUndefined();
expect(normalizeProfileReasoningEffort("")).toBeUndefined();
expect(normalizeProfileReasoningEffort("nope")).toBeUndefined();
});
});
describe("resolveStructuredDeliveryMode", () => {
it("prefers json_schema then forced_tool then json_object", () => {
const base: ProfileCapabilities = {
chat: "ok",
jsonObject: "ok",
jsonSchema: "fail",
tools: "ok",
forcedTool: "ok",
strictTool: "fail",
};
expect(resolveStructuredDeliveryMode(base)).toBe("forced_tool");
expect(
resolveStructuredDeliveryMode({ ...base, jsonSchema: "ok" }),
).toBe("json_schema");
expect(
resolveStructuredDeliveryMode({
...base,
jsonSchema: "fail",
forcedTool: "fail",
}),
).toBe("json_object");
});
});
describe("flat artifact", () => {
it("round-trips deep fragment body into sections", () => {
const flat = coerceToFlatArtifact({
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "点题",
mount: ["narrator"],
稳变: "stable",
正文: {
设定逻辑: { 变造: "丧尸" },
美学纲领: "出租屋",
},
开放问题: ["后宫"],
追问: {
题目: [{ 问: "身份?", 建议选项: ["幸存者", "其他"] }],
},
});
expect(flat?.sections.map((s) => s.id)).toEqual(["设定逻辑", "美学纲领"]);
const frag = flatArtifactToFragmentDoc(flat!);
expect(frag.schema).toBe("context-fragment.v1");
expect(frag.brief).toBe("点题");
expect((frag.追问 as { 题目: unknown[] }).题目[0]).toMatchObject({
问: "身份?",
});
});
it("does not coerce a creation-flow DAG (brief + steps) into a flat artifact", () => {
const flow = {
brief: "单角代入",
status: "open",
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
};
expect(coerceToFlatArtifact(flow)).toBeNull();
});
it("does not coerce a brief-only object into an empty flat artifact", () => {
expect(coerceToFlatArtifact({ brief: "点题" })).toBeNull();
});
it("accepts native flat wire format", () => {
const flat = coerceToFlatArtifact({
schema: FLAT_ARTIFACT_SCHEMA_ID,
skill: "x",
brief: "b",
mount: [],
stability: "semi",
sections: [{ id: "a", title: "A", text: "hello" }],
open_questions: [],
questions: [],
summary: "s",
});
expect(flat?.sections[0]?.text).toBe("hello");
});
it("round-trips nested body objects through flat stringify + fragment parse", () => {
const nested = {
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "点题",
mount: [],
正文: {
交互范式: { 前置配置: { 用户角色: "唯一免疫者" } },
美学纲领: { 体验内核: "报复爽", 呈现要点: "内外对比" },
设定逻辑: { 变造: { 原型: "丧尸" } },
},
开放问题: [],
追问: { 题目: [] },
};
const flat = coerceToFlatArtifact(nested);
expect(flat).not.toBeNull();
const frag = flatArtifactToFragmentDoc(flat!);
expect(frag.技能).toBe("美学纲领与交互范式");
expect((frag.正文 as Record<string, unknown>).交互范式).toEqual({
前置配置: { 用户角色: "唯一免疫者" },
});
expect(
((frag.正文 as Record<string, unknown>).美学纲领 as Record<string, unknown>)
.体验内核,
).toBe("报复爽");
});
});
+23
View File
@@ -48,6 +48,29 @@ describe("pruneContextTraces", () => {
expect(pruned.map((m) => m.id)).toEqual(["a", "b"]);
});
it("pins design-flow traces while pruning others", () => {
const messages = [
{
id: "flow",
contextTrace: buildContextTrace({
caller: "worker:design-flow",
messages: [{ role: "user", content: "plan" }],
}),
},
msg("1", true),
msg("2", true),
msg("3", true),
];
const pruned = pruneContextTraces(messages, 1);
expect(pruned[0]?.contextTrace?.caller).toBe("worker:design-flow");
expect(pruned.map((m) => Boolean(m.contextTrace))).toEqual([
true,
false,
false,
true,
]);
});
it("is a no-op when under the limit", () => {
const messages = [msg("1", true), msg("2", true)];
const pruned = pruneContextTraces(messages, 5);
+393 -34
View File
@@ -6,6 +6,8 @@ import {
artifactTagForStep,
extractModuleOpening,
formatCreationFlowForUser,
deriveFlowStepTitle,
titleFromSuggestion,
formatFlowProgressForAgent,
formatModuleCatalogForAgent,
formatRecipeCatalogForAgent,
@@ -18,6 +20,13 @@ import {
mergeCreationFlowPreservingAccepted,
needsFlowExpansion,
nextPendingStep,
listReadySteps,
computeStepLayers,
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
hasSelectableCreationWork,
removeUnstartedInstance,
looksLikeCreationFlowDoc,
parseCreationFlow,
pickRecordedStepId,
parseModuleCatalog,
@@ -29,6 +38,7 @@ import {
shouldSkipModuleOpening,
isProgressPointerTag,
stringifyCreationFlow,
stripReviseSteps,
creationFlowFromRecipeSeed,
validateCreationFlow,
validateStepReadyToRun,
@@ -51,6 +61,10 @@ modules:
declaration: 锚定实例
artifact: 设计.具体实例
repeatable: true
- name: 开场白与开场变量
declaration: 收口
artifact: 设计.开场白与开场变量
closer: true
`)!;
describe("creation-flow", () => {
@@ -145,7 +159,44 @@ describe("creation-flow", () => {
acceptedStepIds: ["美学纲领与交互范式"],
});
expect(merged.restored).toEqual([]);
expect(merged.raw).toBe(rewritten);
expect(parseCreationFlow(merged.raw)!.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"实现机制",
]);
});
it("drops planner revise steps when merging flow", () => {
const prev = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }
]
}`;
const rewritten = `{
"status": "open",
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{
"id": "美学纲领与交互范式·改",
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["美学纲领与交互范式"]
},
{ "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式·改"] }
]
}`;
const merged = mergeCreationFlowPreservingAccepted({
prevRaw: prev,
nextRaw: rewritten,
acceptedStepIds: ["美学纲领与交互范式"],
});
const flow = parseCreationFlow(merged.raw)!;
expect(flow.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"实现机制",
]);
expect(flow.steps[1]?.depends_on).toEqual(["美学纲领与交互范式"]);
});
it("allows duplicate capability names with distinct ids", () => {
@@ -185,6 +236,24 @@ describe("creation-flow", () => {
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式");
});
it("extracts JSON from a code fence", () => {
const flow = parseCreationFlow(
"```json\n{\"brief\":\"单角代入\",\"steps\":[{\"name\":\"美学纲领与交互范式\",\"depends_on\":[]}]}\n```",
);
expect(flow?.brief).toBe("单角代入");
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式");
});
it("recognizes a DAG object and ignores brief-only objects", () => {
expect(
looksLikeCreationFlowDoc({
brief: "单角代入",
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
}),
).toBe(true);
expect(looksLikeCreationFlowDoc({ brief: "单角代入" })).toBe(false);
});
it("validates deps must appear earlier", () => {
const bad = parseCreationFlow(`{
"steps": [
@@ -250,13 +319,93 @@ describe("creation-flow", () => {
depends_on: [],
declaration: "站位与体验契约",
});
expect(view.steps[2]?.occurrence).toBe(2);
expect(view.steps[2]?.role).toBe("instance");
expect(view.steps[2]?.occurrence).toBe(1);
expect(view.steps[1]?.role).toBe("prototype");
expect(view.steps[2]?.repeatable).toBe(true);
expect(view.steps[0]?.runState).toBe("current");
expect(view.steps[0]?.runState).toBe("pending");
expect(view.steps[0]?.layer).toBe(0);
expect(view.steps[0]?.ready).toBe(true);
expect(view.steps[0]?.selectable).toBe(true);
expect(view.steps[1]?.runState).toBe("pending");
expect(view.steps[1]?.layer).toBe(1);
expect(view.steps[1]?.ready).toBe(false);
expect(view.steps[2]?.runState).toBe("pending");
});
it("shows a short title for 生成规则 / 具体实例 prototypes and instances", () => {
const flow = parseCreationFlow(`{
"steps": [
{
"id": "生成规则",
"name": "生成规则",
"role": "prototype",
"suggestion": "幸存者生成规则:锚定出租屋内的女性租客",
"depends_on": []
},
{
"id": "生成规则#1",
"name": "生成规则",
"role": "instance",
"from": "生成规则",
"depends_on": []
},
{
"id": "具体实例",
"name": "具体实例",
"role": "prototype",
"suggestion": "按幸存者规则生成开局女租客",
"depends_on": ["生成规则"]
},
{
"id": "具体实例#1",
"name": "具体实例",
"role": "instance",
"from": "具体实例",
"params": { "batch_goal": "开局女租客", "rule_id": "survivors" },
"depends_on": ["生成规则#1"]
}
]
}`)!;
const untitled = formatCreationFlowForUser(flow, sampleCatalog);
expect(untitled.steps.find((s) => s.id === "生成规则")?.title).toBe(
"幸存者生成规则",
);
expect(untitled.steps.find((s) => s.id === "生成规则#1")?.title).toBe(
"幸存者生成规则",
);
expect(untitled.steps.find((s) => s.id === "具体实例")?.title).toBe(
"按幸存者规则生成开局女租客",
);
expect(untitled.steps.find((s) => s.id === "具体实例#1")?.title).toBe(
"开局女租客",
);
const accepted = formatCreationFlowForUser(flow, sampleCatalog, [], [], {
"生成规则#1": { summary: "生成规则 · 幸存者 · 预生成并动态" },
"具体实例#1": { summary: "具体实例 · 女租客 · 2条" },
});
expect(accepted.steps.find((s) => s.id === "生成规则#1")?.title).toBe(
"幸存者",
);
expect(accepted.steps.find((s) => s.id === "具体实例#1")?.title).toBe(
"女租客",
);
expect(
deriveFlowStepTitle({
name: "生成规则",
hint: {
content: {
正文: { 本步参数: { target: "女租客" } },
},
},
}),
).toBe("女租客");
expect(titleFromSuggestion("怪物生成规则:按族群抽样")).toBe(
"怪物生成规则",
);
});
it("marks accepted steps done and the next pending current", () => {
const flow = parseCreationFlow(`{
"brief": "网恋回合",
@@ -272,9 +421,10 @@ describe("creation-flow", () => {
]);
expect(view.steps.map((s) => s.runState)).toEqual([
"done",
"current",
"pending",
"pending",
]);
expect(view.steps[1]?.selectable).toBe(true);
});
it("loads world-simulator catalog and formats for agent", async () => {
@@ -317,7 +467,7 @@ describe("creation-flow", () => {
expect(block).toContain("上下文投影排序〔程序步〕");
expect(block).toContain("何时用");
expect(block).toContain("何时不用");
expect(block).toContain("规划产物字段");
expect(block).toContain("role=prototype");
expect(block).toContain("编排参数");
expect(block).not.toContain("设计.美学纲领与交互范式");
});
@@ -403,6 +553,22 @@ steps:
).toBeNull();
});
it("creationFlowFromRecipeSeed follows the recipe steps, not a fixed first node", () => {
const detail = parseRecipeYaml(
`
when: 其它剧本
steps:
- name: 舞台骨架
depends_on: []
- name: 实现机制
depends_on: ["舞台骨架"]
`,
{ id: "other", name: "其它", declaration: "测" },
);
const seeded = creationFlowFromRecipeSeed(detail);
expect(seeded?.steps.map((s) => s.name)).toEqual(["舞台骨架", "实现机制"]);
});
it("falls back to legacy hint when methodology absent", () => {
const detail = parseRecipeYaml(
`
@@ -478,7 +644,7 @@ recipes:
expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("已验收");
expect(loaded.promptBody).toContain("禁止再排新建");
expect(loaded.promptBody).toContain("mode=revise");
expect(loaded.promptBody).toContain("禁止排 mode=revise");
expect(loaded.promptBody).toContain("生成规则");
});
@@ -496,7 +662,36 @@ recipes:
expect(text).toContain("草案已有、尚未验收");
expect(text).toContain("美学纲领与交互范式");
expect(text).toContain("不要当作新规划再写一遍");
expect(text).toContain("可反复追加:生成规则、具体实例");
expect(text).toContain("目录〔可反复〕:生成规则、具体实例");
});
it("tells design-flow to insert nodes before an unaccepted closer", () => {
const catalog = parseModuleCatalog(`
modules:
- name: 美学纲领与交互范式
declaration: 站位
artifact: 设计.美学纲领与交互范式
- name: 开场白与开场变量
declaration: 收口
artifact: 设计.开场白与开场变量
closer: true
`)!;
const flow = parseCreationFlow(`{
"status": "closed",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式"] }
]
}`)!;
const text = formatFlowProgressForAgent({
flow,
acceptedStepIds: ["美学纲领与交互范式"],
catalog,
});
expect(text).toContain("〔收口〕");
expect(text).toContain("开场白与开场变量");
expect(text).toContain("插在收口之前");
expect(text).toContain("status 改回 open");
});
it("injects aesthetics-interaction module prompt into design-step", async () => {
@@ -637,7 +832,7 @@ recipes:
expect(missingView.steps[0]?.paramsMissing).toBeUndefined();
const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("规划产物字段");
expect(block).toContain("role=prototype");
expect(block).toContain("target");
});
@@ -662,8 +857,7 @@ recipes:
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).not.toContain("勿再问「生成什么");
expect(loaded.promptBody).toContain("【本步对象】");
expect(loaded.promptBody).toContain("target: 怪物");
expect(loaded.promptBody).toContain("rule_id: monsters");
expect(loaded.worker.outputTags).toContain("设计.生成规则");
@@ -693,14 +887,22 @@ modules:
undefined,
{
flowRaw: JSON.stringify({
steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }],
steps: [
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: [],
},
],
}),
currentStepName: "生成规则",
currentStepName: "生成规则#1",
acceptedStepNames: [],
},
);
expect(loaded.promptBody).toContain("【规划产物】");
expect(loaded.promptBody).toContain("尚未规划本步具体写什么");
expect(loaded.promptBody).toContain("【本步对象】");
expect(loaded.promptBody).toContain("尚未钉本步具体写什么");
});
it("nextPendingStep respects deps and accepted by id", () => {
@@ -780,7 +982,7 @@ modules:
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true);
});
it("allows revising a non-repeatable skill and infers revises + id", () => {
it("parses legacy revise steps then strips them from the user graph", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
@@ -795,10 +997,35 @@ modules:
revises: "实现机制",
});
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
const stripped = stripReviseSteps(flow);
expect(stripped.steps.map((s) => s.id)).toEqual(["实现机制"]);
const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]);
expect(view.steps[1]?.mode).toBe("revise");
expect(view.steps[1]?.occurrence).toBeUndefined();
expect(view.steps[1]?.runState).toBe("current");
expect(view.steps).toHaveLength(1);
expect(view.steps[0]?.id).toBe("实现机制");
expect(view.steps[0]?.runState).toBe("done");
expect(view.steps[0]?.mode).toBeUndefined();
});
it("stripReviseSteps remaps dependents back to the origin step", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{
"id": "美学纲领与交互范式·改",
"name": "美学纲领与交互范式",
"mode": "revise",
"revises": "美学纲领与交互范式",
"depends_on": ["美学纲领与交互范式"]
},
{ "id": "正文组成", "name": "实现机制", "depends_on": ["美学纲领与交互范式·改"] }
]
}`)!;
const stripped = stripReviseSteps(flow);
expect(stripped.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"正文组成",
]);
expect(stripped.steps[1]?.depends_on).toEqual(["美学纲领与交互范式"]);
});
it("still rejects two fresh copies of a non-repeatable skill", () => {
@@ -849,7 +1076,7 @@ modules:
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
});
it("nextPendingStep prefers a ready revise over a later fresh step", () => {
it("nextPendingStep ignores orchestrated revise steps", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
@@ -863,31 +1090,26 @@ modules:
}
]
}`)!;
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise");
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe(
"美学纲领与交互范式·改",
);
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBeUndefined();
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe("生成规则");
expect(
listReadySteps(flow, ["美学纲领与交互范式"]).map((s) => s.id),
).toEqual(["生成规则"]);
});
it("injects inherit draft and skips opening on a revise design-step", async () => {
it("inherits existing artifact when reentering an accepted step", async () => {
const flowRaw = JSON.stringify({
status: "open",
steps: [
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
{
id: "美学纲领与交互范式·改",
name: "美学纲领与交互范式",
mode: "revise",
revises: "美学纲领与交互范式",
depends_on: ["美学纲领与交互范式"],
},
],
});
const binding = await resolveDesignStepBinding({
skillPackRoot: "dialogue/world-simulator",
flowRaw,
currentStepName: "美学纲领与交互范式·改",
currentStepName: "美学纲领与交互范式",
acceptedStepNames: ["美学纲领与交互范式"],
inheritExisting: true,
});
expect(binding?.opening).toBeNull();
expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式");
@@ -898,16 +1120,153 @@ modules:
undefined,
{
flowRaw,
currentStepName: "美学纲领与交互范式·改",
currentStepName: "美学纲领与交互范式",
acceptedStepNames: ["美学纲领与交互范式"],
inheritExisting: true,
},
);
expect(loaded.promptBody).toContain("【回头修改】");
expect(loaded.promptBody).not.toContain("程序开场");
expect(loaded.worker.name).toContain("回头修改");
expect(loaded.worker.inputTags).toContain("设计.美学纲领与交互范式");
});
it("layers follow longest dependency path", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "name": "具体实例", "depends_on": ["生成规则"] },
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式", "具体实例"] }
]
}`)!;
const layers = computeStepLayers(flow);
expect(layers.get("美学纲领与交互范式")).toBe(0);
expect(layers.get("生成规则")).toBe(1);
expect(layers.get("具体实例")).toBe(2);
expect(layers.get("开场白与开场变量")).toBe(3);
});
it("listReadySteps returns every unblocked pending step", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式"] }
]
}`)!;
expect(listReadySteps(flow, []).map((s) => s.id)).toEqual([
"美学纲领与交互范式",
]);
expect(
loaded.worker.contextSegments?.some((s) => s.id === "inherit-existing"),
listReadySteps(flow, ["美学纲领与交互范式"]).map((s) => s.id),
).toEqual(["生成规则", "实现机制"]);
});
it("spawns instance from prototype before closer and hangs instance on closer deps", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式"] }
]
}`)!;
expect(
hasSelectableCreationWork(flow, sampleCatalog, ["美学纲领与交互范式"]),
).toBe(true);
const spawned = spawnRepeatableCreationStep({
flow,
catalog: sampleCatalog,
moduleName: "生成规则",
acceptedStepIds: ["美学纲领与交互范式"],
});
expect("error" in spawned).toBe(false);
if ("error" in spawned) return;
expect(spawned.step.id).toBe("生成规则#1");
expect(spawned.step.role).toBe("instance");
expect(spawned.step.depends_on).toEqual(["美学纲领与交互范式"]);
expect(spawned.flow.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"生成规则",
"生成规则#1",
"开场白与开场变量",
]);
expect(
spawned.flow.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式", "生成规则#1"]);
const second = spawnRepeatableCreationStep({
flow: spawned.flow,
catalog: sampleCatalog,
moduleName: "生成规则",
acceptedStepIds: ["美学纲领与交互范式"],
});
expect("error" in second).toBe(false);
if ("error" in second) return;
expect(second.step.id).toBe("生成规则#2");
});
it("removeUnstartedInstance drops empty spawn and closer dep", () => {
const flow = parseCreationFlow(`{
"status": "open",
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "role": "prototype", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#1", "name": "生成规则", "role": "instance", "from": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式", "生成规则#1"] }
]
}`)!;
const next = removeUnstartedInstance(flow, "生成规则#1", []);
expect(next.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"生成规则",
"开场白与开场变量",
]);
expect(
next.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式"]);
const kept = removeUnstartedInstance(next, "美学纲领与交互范式", []);
expect(kept.steps.map((s) => s.id)).toEqual(next.steps.map((s) => s.id));
const accepted = removeUnstartedInstance(flow, "生成规则#1", ["生成规则#1"]);
expect(accepted.steps.some((s) => s.id === "生成规则#1")).toBe(true);
});
it("blocks 具体实例 spawn until a 生成规则 instance is accepted", () => {
const flow = parseCreationFlow(`{
"steps": [
{ "name": "美学纲领与交互范式", "depends_on": [] },
{
"id": "生成规则",
"name": "生成规则",
"role": "prototype",
"depends_on": ["美学纲领与交互范式"]
}
]
}`)!;
const blocked = spawnRepeatableCreationStep({
flow,
catalog: sampleCatalog,
moduleName: "具体实例",
acceptedStepIds: ["美学纲领与交互范式"],
});
expect(blocked).toMatchObject({ error: "先完成一条生成规则" });
const ruleSpawn = spawnInstanceFromPrototype({
flow,
catalog: sampleCatalog,
prototypeId: "生成规则",
acceptedStepIds: ["美学纲领与交互范式"],
});
expect("error" in ruleSpawn).toBe(false);
if ("error" in ruleSpawn) return;
const withRule = ruleSpawn.flow;
const ok = spawnRepeatableCreationStep({
flow: withRule,
catalog: sampleCatalog,
moduleName: "具体实例",
acceptedStepIds: ["美学纲领与交互范式", "生成规则#1"],
});
expect("error" in ok).toBe(false);
if ("error" in ok) return;
expect(ok.step.depends_on).toEqual(["生成规则#1"]);
});
});
+10 -1
View File
@@ -8,12 +8,21 @@ import {
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
describe("resolveAcceptanceModeForWorker", () => {
it("always confirms design-flow", () => {
it("auto-continues design-flow without a confirm step", () => {
const mode = resolveAcceptanceModeForWorker({
session: createSession(),
blackboard: new Blackboard(),
workerId: "design-flow",
});
expect(mode).toBe("no_confirmation");
});
it("still confirms design-step", () => {
const mode = resolveAcceptanceModeForWorker({
session: createSession(),
blackboard: new Blackboard(),
workerId: "design-step",
});
expect(mode).toBe("user_confirmed");
});
+22
View File
@@ -3,6 +3,7 @@ import {
displayStageLabel,
displayWorkerLabel,
formatWorkerDisplayTitle,
isFlowPlanReview,
reviewComposerCopy,
} from "../src/server/display-labels.js";
@@ -52,4 +53,25 @@ describe("display-labels", () => {
expect(setup.acceptLabel).toBe("选定此开场");
expect(setup.kind).toBe("opening");
});
it("treats design-flow review as the pick-node surface", () => {
expect(
isFlowPlanReview({
waitingReason: { kind: "review_artifact" },
reviewArtifact: { workerId: "design-flow" },
}),
).toBe(true);
expect(
isFlowPlanReview({
waitingReason: { kind: "review_artifact" },
reviewArtifact: { workerId: "design-step" },
}),
).toBe(false);
expect(
isFlowPlanReview({
waitingReason: { kind: "pick_creation_step" },
reviewArtifact: { workerId: "design-flow" },
}),
).toBe(false);
});
});
+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import {
applyOpenAiGeneration,
applyRejectedGenerationField,
classifyChatModel,
fetchWithGenerationCompat,
parseRejectedGenerationField,
sanitizeReasoningEffort,
sanitizeVerbosity,
} from "../src/llm/generation-compat.js";
import { buildRequestBody } from "../src/llm/client.js";
import type { LlmConfig } from "../src/config/env.js";
const stGeneration = {
temperature: 1,
topP: 0.95,
topK: 40,
minP: 0.05,
frequencyPenalty: 0,
presencePenalty: 0,
repetitionPenalty: 1.05,
maxOutputTokens: 60000,
seed: -1,
reasoningEffort: "auto",
verbosity: "auto",
};
describe("sanitizeReasoningEffort", () => {
it("omits SillyTavern auto and empty values", () => {
expect(sanitizeReasoningEffort("auto")).toBeUndefined();
expect(sanitizeReasoningEffort("AUTO")).toBeUndefined();
expect(sanitizeReasoningEffort("")).toBeUndefined();
expect(sanitizeReasoningEffort(" ")).toBeUndefined();
expect(sanitizeReasoningEffort(undefined)).toBeUndefined();
});
it("keeps values the provider enum accepts", () => {
expect(sanitizeReasoningEffort("none")).toBe("none");
expect(sanitizeReasoningEffort("minimal")).toBe("minimal");
expect(sanitizeReasoningEffort("low")).toBe("low");
expect(sanitizeReasoningEffort("medium")).toBe("medium");
expect(sanitizeReasoningEffort("high")).toBe("high");
expect(sanitizeReasoningEffort("xhigh")).toBe("xhigh");
expect(sanitizeReasoningEffort("max")).toBe("max");
});
it("maps SillyTavern min to minimal and ignores unknown values", () => {
expect(sanitizeReasoningEffort("min")).toBe("minimal");
expect(sanitizeReasoningEffort("MIN")).toBe("minimal");
expect(sanitizeReasoningEffort("unknown")).toBeUndefined();
});
});
describe("sanitizeVerbosity", () => {
it("omits auto and unknown values", () => {
expect(sanitizeVerbosity("auto")).toBeUndefined();
expect(sanitizeVerbosity("loud")).toBeUndefined();
expect(sanitizeVerbosity("medium")).toBe("medium");
});
});
describe("classifyChatModel", () => {
it("treats o-series, gpt-5, kimi-k2.5 and deepseek-reasoner as no-sampling", () => {
expect(classifyChatModel("o3-mini").omitSampling).toBe(true);
expect(classifyChatModel("openai/o4-mini").omitSampling).toBe(true);
expect(classifyChatModel("gpt-5").omitSampling).toBe(true);
expect(classifyChatModel("deepseek-reasoner").omitSampling).toBe(true);
expect(classifyChatModel("kimi-k2.5").omitSampling).toBe(true);
});
it("keeps sampling for ordinary chat models", () => {
expect(classifyChatModel("gpt-4o").omitSampling).toBe(false);
expect(classifyChatModel("deepseek-v4-pro").omitSampling).toBe(false);
expect(classifyChatModel("gpt-5-chat-latest").omitSampling).toBe(false);
});
});
describe("applyOpenAiGeneration", () => {
it("does not send ST extras or auto sentinels on a chat model", () => {
const body: Record<string, unknown> = { model: "deepseek-v4-pro" };
applyOpenAiGeneration(body, stGeneration, "deepseek-v4-pro");
expect(body.temperature).toBe(1);
expect(body.top_p).toBe(0.95);
expect(body.max_tokens).toBe(60000);
expect(body.top_k).toBeUndefined();
expect(body.min_p).toBeUndefined();
expect(body.repetition_penalty).toBeUndefined();
expect(body.reasoning_effort).toBeUndefined();
expect(body.verbosity).toBeUndefined();
expect(body.seed).toBeUndefined();
});
it("drops sampling params for reasoning models and uses max_completion_tokens", () => {
const body: Record<string, unknown> = { model: "o3-mini" };
applyOpenAiGeneration(body, stGeneration, "o3-mini");
expect(body.temperature).toBeUndefined();
expect(body.top_p).toBeUndefined();
expect(body.frequency_penalty).toBeUndefined();
expect(body.presence_penalty).toBeUndefined();
expect(body.max_tokens).toBeUndefined();
expect(body.max_completion_tokens).toBe(60000);
});
});
describe("parseRejectedGenerationField", () => {
it("reads OpenAI unsupported parameter errors", () => {
expect(
parseRejectedGenerationField(
JSON.stringify({
error: {
message: "Unsupported parameter: 'temperature' is not supported with this model.",
param: "temperature",
},
}),
),
).toBe("temperature");
});
it("reads strict JSON deserializer unknown variant errors", () => {
expect(
parseRejectedGenerationField(
JSON.stringify({
error: {
message:
"Failed to deserialize the JSON body into the target type: reasoning_effort: unknown variant `auto`, expected one of `none`, `minimal`",
},
}),
),
).toBe("reasoning_effort");
});
it("drops top_p when temperature and top_p are mutually exclusive", () => {
expect(
parseRejectedGenerationField(
"temperature and top_p cannot both be specified for this model",
),
).toBe("top_p");
});
});
describe("applyRejectedGenerationField", () => {
it("renames max_completion_tokens to max_tokens once", () => {
const body: Record<string, unknown> = { max_completion_tokens: 100 };
const tried = new Set<string>();
expect(applyRejectedGenerationField(body, "max_completion_tokens", tried)).toBe(
true,
);
expect(body.max_tokens).toBe(100);
expect(body.max_completion_tokens).toBeUndefined();
expect(applyRejectedGenerationField(body, "max_tokens", tried)).toBe(true);
expect(body.max_completion_tokens).toBeUndefined();
expect(body.max_tokens).toBeUndefined();
});
});
describe("fetchWithGenerationCompat", () => {
it("retries a 400 after stripping the rejected field", async () => {
const sent: Array<Record<string, unknown>> = [];
const response = await fetchWithGenerationCompat(async (body) => {
sent.push({ ...body });
if ("temperature" in body) {
return new Response(
JSON.stringify({
error: {
message: "Unsupported parameter: 'temperature'",
param: "temperature",
},
}),
{ status: 400 },
);
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}, { model: "o3", temperature: 1, messages: [] });
expect(response.ok).toBe(true);
expect(sent).toHaveLength(2);
expect(sent[0].temperature).toBe(1);
expect(sent[1].temperature).toBeUndefined();
});
});
describe("buildRequestBody", () => {
const messages = [{ role: "user" as const, content: "hi" }];
it("does not send reasoning_effort when the preset says auto", () => {
const config: LlmConfig = {
baseUrl: "https://example.test/v1",
apiKey: "k",
model: "deepseek-v4-pro",
};
const body = buildRequestBody(config, messages, {
generation: { reasoningEffort: "auto", temperature: 0.5 },
});
expect(body.reasoning_effort).toBeUndefined();
expect(body.temperature).toBe(0.5);
});
it("sends a supported effort value", () => {
const config: LlmConfig = {
baseUrl: "https://example.test/v1",
apiKey: "k",
model: "gpt-5",
};
const body = buildRequestBody(config, messages, {
generation: { reasoningEffort: "High" },
});
expect(body.reasoning_effort).toBe("high");
expect(body.temperature).toBeUndefined();
});
it("uses profile default reasoningEffort when generation omits it", () => {
const config: LlmConfig = {
baseUrl: "https://example.test/v1",
apiKey: "k",
model: "glm-5.3",
reasoningEffort: "high",
};
const body = buildRequestBody(config, messages, {
generation: { temperature: 1 },
});
expect(body.reasoning_effort).toBe("high");
});
it("lets generation reasoningEffort override the profile default", () => {
const config: LlmConfig = {
baseUrl: "https://example.test/v1",
apiKey: "k",
model: "glm-5.3",
reasoningEffort: "max",
};
const body = buildRequestBody(config, messages, {
generation: { reasoningEffort: "low" },
});
expect(body.reasoning_effort).toBe("low");
});
it("does not forward top_k from an imported ST preset", () => {
const config: LlmConfig = {
baseUrl: "https://example.test/v1",
apiKey: "k",
model: "deepseek-v4-pro",
};
const body = buildRequestBody(config, messages, {
generation: stGeneration,
});
expect(body.top_k).toBeUndefined();
expect(body.min_p).toBeUndefined();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
convertStructuralSmartQuotes,
tryParseJsonDoc,
} from "../web/json-doc.js";
describe("tryParseJsonDoc", () => {
it("keeps interior Chinese quotes in an otherwise valid fragment", () => {
const raw = JSON.stringify({
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
正文: {
设定逻辑: {
参与方式: {
用户与user关系: {
依据: "用户明确“只有我不会被感染”,且为爽文主角",
},
},
},
},
});
const parsed = tryParseJsonDoc(`## 设计.美学纲领与交互范式\n\n${raw}`);
expect(parsed?.技能).toBe("美学纲领与交互范式");
expect(parsed?.正文?.设定逻辑?.参与方式?.用户与user关系?.依据).toContain(
"只有我不会被感染",
);
});
it("parses JSON that uses smart quotes as delimiters", () => {
const raw = "{“schema”:“context-fragment.v1”,“brief”:“点题”}";
expect(convertStructuralSmartQuotes(raw)).toBe(
'{"schema":"context-fragment.v1","brief":"点题"}',
);
expect(tryParseJsonDoc(raw)).toEqual({
schema: "context-fragment.v1",
brief: "点题",
});
});
});
+11
View File
@@ -4,6 +4,7 @@ import {
isOpeningSealArtifact,
parseOpeningSealPayload,
openingVariablesToTableDoc,
reopenCreationFlowRaw,
} from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({
@@ -71,4 +72,14 @@ describe("opening-seal", () => {
const closed = JSON.parse(closeCreationFlowRaw(raw)!);
expect(closed.status).toBe("closed");
});
it("reopens a closed creation flow", () => {
const raw = JSON.stringify({
version: 1,
status: "closed",
steps: [{ id: "开场白与开场变量", name: "开场白与开场变量", depends_on: [] }],
});
const opened = JSON.parse(reopenCreationFlowRaw(raw)!);
expect(opened.status).toBe("open");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
extractLastProductJson,
selectJsonPayload,
tryParseJsonDoc,
} from "../src/parse/json-doc.js";
describe("selectJsonPayload", () => {
it("keeps content when it is already the product", () => {
const json = '{"schema":"context-fragment.v1","brief":"a","正文":{}}';
expect(selectJsonPayload(json, "thinking...")).toBe(json);
});
it("falls back to reasoning when content is not JSON", () => {
const json = '{"schema":"context-fragment.v1","brief":"a","正文":{}}';
expect(selectJsonPayload("Done.", json)).toBe(json);
});
});
describe("extractLastProductJson", () => {
it("skips an earlier example object and takes the fragment", () => {
const raw = `example { "foo": 1 } then ${JSON.stringify({
schema: "context-fragment.v1",
brief: "点题",
正文: { x: 1 },
})}`;
const parsed = extractLastProductJson(raw) as { brief?: string };
expect(parsed?.brief).toBe("点题");
expect(tryParseJsonDoc(raw)).toMatchObject({ brief: "点题" });
});
});
+156
View File
@@ -541,4 +541,160 @@ describe("phase machine", () => {
false,
);
});
it("rejecting the next step goes back to design-flow instead of the main agent", () => {
let session = createSession("default");
const decision = {
id: "d1",
action: "run_worker" as const,
reason: "下一步开场白",
workerId: "design-step",
requiresApproval: true,
statePatchAllowed: false as const,
};
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "approve_step", decisionId: decision.id },
pendingDecision: decision,
};
const rejected = applyEvent(session, {
type: "user_rejected_next_step",
payload: { decisionId: decision.id, reason: "还没做 NPC" },
});
expect(rejected.session.phase).toBe("running");
expect(rejected.session.slots["用户.下一步意向"]).toBe("还没做 NPC");
expect(rejected.session.pendingDecision).toBeUndefined();
expect(rejected.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
expect(rejected.effects.some((e) => e.type === "invoke_main_agent")).toBe(
false,
);
});
it("replan from opening review runs design-flow without revising the opening", () => {
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 },
slots: { creationSealedByOpening: true },
};
const replanned = applyEvent(session, {
type: "user_requested_flow_replan",
payload: { reason: "漏了开局 NPC" },
});
expect(replanned.session.phase).toBe("running");
expect(replanned.session.pendingArtifactId).toBeUndefined();
expect(replanned.session.slots.creationSealedByOpening).toBeUndefined();
expect(replanned.session.artifacts[0]?.status).toBe("rejected");
expect(replanned.session.slots["用户.修订说明"]).toBe("漏了开局 NPC");
expect(replanned.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
});
it("awaits pick on the layered graph and starts the clicked step", () => {
let session = createSession("default");
session = {
...session,
phase: "running",
};
const awaited = applyEvent(session, {
type: "creation_step_pick_awaited",
payload: {},
});
expect(awaited.session.phase).toBe("waiting_user");
expect(awaited.session.waitingReason?.kind).toBe("pick_creation_step");
expect(awaited.session.currentWorkerId).toBeUndefined();
const picked = applyEvent(awaited.session, {
type: "user_picked_creation_step",
payload: { stepId: "生成规则" },
});
expect(picked.session.phase).toBe("running");
expect(picked.session.currentWorkerId).toBe("design-step");
expect(picked.effects).toEqual([
{ type: "run_worker", workerId: "design-step" },
]);
});
it("leaving a creation step returns to the pick graph", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: {
kind: "worker_questions",
workerId: "design-step",
questions: [{ id: "module-opening", prompt: "引导" }],
},
currentWorkerId: "design-step",
currentStepId: "生成规则#1",
};
const left = applyEvent(session, {
type: "user_left_creation_step",
payload: {},
});
expect(left.session.phase).toBe("waiting_user");
expect(left.session.waitingReason?.kind).toBe("pick_creation_step");
expect(left.session.currentWorkerId).toBeUndefined();
expect(left.session.currentStepId).toBeUndefined();
expect(left.session.resumeContext).toBeUndefined();
expect(left.effects).toEqual([]);
});
it("replan from the pick graph runs design-flow", () => {
let session = createSession("default");
session = {
...session,
phase: "waiting_user",
waitingReason: { kind: "pick_creation_step" },
};
const replanned = applyEvent(session, {
type: "user_requested_flow_replan",
payload: { reason: "补舞台骨架" },
});
expect(replanned.session.phase).toBe("running");
expect(replanned.session.slots["用户.下一步意向"]).toBe("补舞台骨架");
expect(replanned.effects).toEqual([
{ type: "unseal_creation_opening" },
{ type: "run_worker", workerId: "design-flow" },
]);
});
it("design-flow without confirmation proposes the pick graph", () => {
let session = createSession("default");
session = {
...session,
phase: "running",
acceptanceMode: "no_confirmation",
};
const artifact = createArtifact({
workerId: "design-flow",
outputTags: ["设计.创作流程"],
});
session = { ...session, artifacts: [artifact] };
const completed = applyEvent(session, {
type: "worker_completed",
payload: { artifactId: artifact.id },
});
expect(completed.session.artifacts[0]?.status).toBe("accepted");
expect(completed.session.pendingArtifactId).toBeUndefined();
expect(completed.effects).toEqual([
{ type: "propose_next_creation_step" },
]);
});
});
+164
View File
@@ -7,6 +7,7 @@ import {
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
import { createSession } from "../src/runtime/phase-machine.js";
import { Blackboard } from "../src/blackboard/blackboard.js";
import { parseCreationFlow } from "../src/skills/creation-flow.js";
describe("phase runtime", () => {
it("auto-loads default orchestrator and awaits first user input", async () => {
@@ -129,4 +130,167 @@ describe("phase runtime", () => {
await runtime.start();
await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行");
});
it("first input with seeded DAG waits for node pick instead of calling main agent", async () => {
const llm = new MockLlmProvider(["Let me analyze the DAG in English prose"]);
const runtime = new PhaseRuntime({ llm });
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
],
}),
source: "runtime",
});
await runtime.submitInput("丧尸世界,但只有我不会被感染");
expect(runtime.getSession().phase).toBe("waiting_user");
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
});
it("enterSeededCreationPick skips first demand and waits on the seeded graph", async () => {
const runtime = new PhaseRuntime();
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "舞台骨架",
name: "舞台骨架",
depends_on: [],
},
],
}),
source: "runtime",
});
await runtime.enterSeededCreationPick();
expect(runtime.getSession().slots.startupCompleted).toBe(true);
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
});
it("unusable main agent output without DAG starts design-flow", async () => {
const llm = new MockLlmProvider(["Let me think about routing"]);
const runtime = new PhaseRuntime({ llm, autoStubWorker: true });
await runtime.start();
await runtime.submitInput("网恋对象对话");
expect(runtime.getSession().phase).not.toBe("running");
expect(
runtime.getSession().artifacts.some((a) => a.workerId === "design-flow"),
).toBe(true);
});
it("failRun leaves error phase instead of running", async () => {
const runtime = new PhaseRuntime();
await runtime.start();
await runtime.submitInput("随便写点");
expect(runtime.getSession().phase).toBe("running");
await runtime.failRun("fetch failed");
expect(runtime.getSession().phase).toBe("error");
expect(runtime.getSession().waitingReason).toBeUndefined();
});
it("recoverOrphanedRun turns stuck running into node pick when DAG exists", async () => {
const runtime = new PhaseRuntime();
await runtime.start();
runtime.getBlackboard().write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
],
}),
source: "runtime",
});
const session = runtime.getSession();
session.phase = "running";
session.waitingReason = undefined;
expect(runtime.recoverOrphanedRun()).toBe(true);
expect(runtime.getSession().phase).toBe("waiting_user");
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
});
it("leaveCreationStep returns to pick and unsawns an empty instance", async () => {
const session = createSession("default");
session.phase = "waiting_user";
session.waitingReason = {
kind: "worker_questions",
workerId: "design-step",
questions: [{ id: "module-opening", prompt: "生成规则引导" }],
};
session.currentWorkerId = "design-step";
session.slots = { startupCompleted: true };
const blackboard = new Blackboard();
blackboard.write({
tag: "设计.创作流程",
content: JSON.stringify({
version: 1,
status: "open",
steps: [
{
id: "美学纲领与交互范式",
name: "美学纲领与交互范式",
depends_on: [],
},
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: ["美学纲领与交互范式"],
},
{
id: "开场白与开场变量",
name: "开场白与开场变量",
depends_on: ["美学纲领与交互范式", "生成规则#1"],
},
],
}),
source: "runtime",
});
blackboard.write({
tag: "创作.当前步骤",
content: "生成规则#1",
source: "runtime",
});
const runtime = new PhaseRuntime({
initialSession: session,
initialBlackboardItems: blackboard.exportItems(),
});
await runtime.leaveCreationStep();
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
expect(runtime.getSession().currentWorkerId).toBeUndefined();
const flow = parseCreationFlow(
runtime.getBlackboard().getContentByTag("设计.创作流程"),
);
expect(flow?.steps.map((s) => s.id)).toEqual([
"美学纲领与交互范式",
"生成规则",
"开场白与开场变量",
]);
expect(
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
).toEqual(["美学纲领与交互范式"]);
});
});
+28
View File
@@ -127,4 +127,32 @@ describe("session persistence", () => {
deleteBook(book.id);
});
it("createForBook with recipe lands on that recipe's seeded pick graph", async () => {
const { createBook, deleteBook } = await import("../src/book/store.js");
const { SessionManager } = await import("../src/server/session-manager.js");
const book = createBook({ title: "配方开局" });
const mgr = new SessionManager();
const view = await mgr.createForBook(
book.id,
"world-simulator",
"world-simulator",
);
expect(view.waitingReason?.kind).toBe("pick_creation_step");
expect(view.startupCompleted).toBe(true);
expect(view.uiPrompt).toBeUndefined();
expect(view.openingGuide).toBeNull();
const { loadAllRecipeDetails } = await import(
"../src/skills/creation-flow.js"
);
const details = await loadAllRecipeDetails("dialogue/world-simulator");
const seedNames = details
.find((d) => d.id === "world-simulator")
?.seed?.steps.map((s) => s.name);
expect(seedNames?.length).toBeGreaterThan(0);
expect(view.creationFlowView?.steps.map((s) => s.name)).toEqual(seedNames);
deleteBook(book.id);
});
});
+10
View File
@@ -4,6 +4,7 @@ import {
createMockToolCall,
MockLlmProvider,
} from "../src/llm/client.js";
import { isUnusableMainAgentOutput } from "../src/main-agent/main-agent.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";
@@ -29,6 +30,15 @@ describe("main agent tool loop", () => {
).toEqual(["read_blackboard", "run_worker"]);
});
it("detects unusable main agent output", () => {
expect(
isUnusableMainAgentOutput(
new Error("Main Agent returned invalid JSON: Let me analyze"),
),
).toBe(true);
expect(isUnusableMainAgentOutput(new Error("fetch failed"))).toBe(false);
});
it("runs loop tools then returns terminal decision", async () => {
const llm = new MockLlmProvider([
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
+120
View File
@@ -9,6 +9,8 @@ import {
parseWorkerResponseForTest,
sanitizeWorkerSetOutputs,
} from "../src/worker/executor.js";
import { selectJsonPayload } from "../src/parse/json-doc.js";
import { parseCreationFlow } from "../src/skills/creation-flow.js";
describe("worker-set prose rejection", () => {
it("does not YAML-crash on Chinese prose; returns clear parseError", () => {
@@ -155,6 +157,39 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.askUser?.[0]?.prompt).not.toContain("为贴近你要的质感");
});
it("keeps nested aesthetics 正文 objects for specialty cards", () => {
const frag = {
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "孤立免疫",
正文: {
设定逻辑: {
参与方式: {
用户与user关系: {
结论: "完全代入",
完备度: "80%",
依据: "用户明确只有我不会被感染",
},
},
},
交互范式: { 前置配置: { 人称: "你" } },
美学纲领: { 体验内核: "特权与惊惶" },
},
自评: {
维度: [{ 名: "美学纲领", 分数: 8, 说明: "点题" }],
},
};
const result = parseWorkerResponseForTest(JSON.stringify(frag), [
"设计.美学纲领与交互范式",
]);
const parsed = JSON.parse(result.outputs["设计.美学纲领与交互范式"]!);
const body = parsed.正文;
expect(typeof body.设定逻辑).toBe("object");
expect(body.设定逻辑.参与方式.用户与user关系.结论).toBe("完全代入");
expect(body.美学纲领.体验内核).toBe("特权与惊惶");
expect(parsed.自评.维度[0].分数).toBe(8);
});
it("recovers a root-level context-fragment into the artifact tag", () => {
const frag = {
schema: "context-fragment.v1",
@@ -173,6 +208,46 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.askUser).toBeUndefined();
});
it("accepts unprefixed outputs keys and JSON sitting after thinking prose", () => {
const frag = {
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "孤立免疫",
正文: { 美学纲领: { 体验内核: "人人录我" } },
};
const prefixed = parseWorkerResponseForTest(
JSON.stringify({
outputs: { "美学纲领与交互范式": frag },
summary: "ok",
}),
["设计.美学纲领与交互范式"],
);
expect(prefixed.outputs["设计.美学纲领与交互范式"]).toContain("人人录我");
const mixed = parseWorkerResponseForTest(
`We must output JSON. Example { "foo": 1 }.\n${JSON.stringify(frag)}`,
["设计.美学纲领与交互范式"],
);
expect(mixed.outputs["设计.美学纲领与交互范式"]).toContain("人人录我");
});
it("uses product JSON in reasoning when content is a closing sentence", () => {
const frag = {
schema: "context-fragment.v1",
技能: "美学纲领与交互范式",
brief: "点题",
正文: { 美学纲领: { 体验内核: "内核" } },
};
const payload = selectJsonPayload(
"The artifact is ready.",
JSON.stringify(frag),
);
const result = parseWorkerResponseForTest(payload, [
"设计.美学纲领与交互范式",
]);
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("内核");
});
it("moves a fragment written to 创作.当前步骤 onto the artifact tag", () => {
const frag = {
schema: "context-fragment.v1",
@@ -257,6 +332,51 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(doc.play_slots.gm).toBe(true);
});
it("keeps a root-level creation flow DAG on 设计.创作流程", () => {
const dag = {
brief: "单角代入",
status: "open",
steps: [
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
{
id: "生成规则",
name: "生成规则",
role: "prototype",
depends_on: ["美学纲领与交互范式"],
},
],
};
const result = parseWorkerResponseForTest(JSON.stringify(dag), [
"设计.创作流程",
]);
const parsed = parseCreationFlow(result.outputs["设计.创作流程"]);
expect(parsed?.brief).toBe("单角代入");
expect(parsed?.steps.map((s) => s.name)).toEqual([
"美学纲领与交互范式",
"生成规则",
]);
expect(result.outputs["设计.创作流程"]).not.toContain("context-fragment");
expect(result.askUser).toBeUndefined();
});
it("keeps creation flow written under outputs bag", () => {
const result = parseWorkerResponseForTest(
JSON.stringify({
outputs: {
"设计.创作流程": {
status: "open",
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
},
},
summary: "流程 · 1 步 · open",
}),
["设计.创作流程"],
);
expect(parseCreationFlow(result.outputs["设计.创作流程"])?.steps[0]?.name).toBe(
"美学纲领与交互范式",
);
});
it("keeps a whole-response truncated JSON as the artifact draft", () => {
const raw =
'{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":[';
+816 -192
View File
File diff suppressed because it is too large Load Diff
+259 -65
View File
@@ -1,7 +1,7 @@
import { renderWorkspace, updateLiveStreamPanel, resetRailChrome } from "./agent-ui.js";
import { renderWorkspace, updateLiveStreamPanel, resetRailChrome, fillStepArtifactDialog } from "./agent-ui.js";
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js";
import { displaySkillPackLabel, reviewComposerCopy } from "./display-labels.js";
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import {
clearQuestionCardState,
collectQuestionAnswers,
@@ -17,9 +17,11 @@ let books = [];
let composerForceInput = false;
let pendingComposerDraft = "";
let pendingComposerDraftSeq = 0;
/** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */
let skipComposerStash = false;
let sidebarNav = { level: "root" };
let bookSelectMode = false;
let selectedBookIds = new Set();
const expandedBookIds = new Set();
let activePlaySaveId = null;
const playSavesByBook = new Map();
const playSavesLoading = new Set();
@@ -97,6 +99,15 @@ function resolveUserTask(view, loading) {
};
}
if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") {
return {
id: "speak",
label: "选节点",
title: "选要做的节点",
hint: "点可进入的节点即确认并开始;要改排在底栏写意见再发。",
};
}
if (wr?.kind === "review_artifact") {
const copy = reviewCopyFromView(view);
return {
@@ -388,6 +399,22 @@ function resolveComposer(view, loading) {
const send = view.actions?.find((a) => a.type === "send_message");
const hasQuestionsCard = Boolean(getActiveQuestions(view)?.questions?.length);
if (
(view.waitingReason?.kind === "pick_creation_step" || isFlowPlanReview(view)) &&
!composerForceInput
) {
return {
mode: "pick_step",
task,
taskTitle: "选要做的节点",
taskHint: "点可进入的节点即确认并开始;虚线原型点一下增殖。要改排在底栏写意见再发。",
placeholder: send?.placeholder ?? "例如:补舞台骨架、把某步改到开场白之前…",
hint: null,
submitLabel: "发送",
requiresText: true,
};
}
if (view.waitingReason?.kind === "review_artifact") {
const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError);
const copy = reviewCopyFromView(view);
@@ -432,6 +459,7 @@ function resolveComposer(view, loading) {
view.waitingReason?.kind === "worker_questions" ||
view.waitingReason?.kind === "revision" ||
view.waitingReason?.kind === "next_intent" ||
view.waitingReason?.kind === "pick_creation_step" ||
composerForceInput
) {
const wr = view.waitingReason;
@@ -442,7 +470,7 @@ function resolveComposer(view, loading) {
submitLabel = "继续";
} else if (wr?.kind === "approve_step" && composerForceInput) {
placeholder =
"例如:改成丧尸怪物规则(后台 id 也换成 zombies)…";
"例如:补开局 NPC、改成丧尸怪物规则(后台 id 也换成 zombies)…";
submitLabel = "发送意见";
} else if (wr?.kind === "revision") {
placeholder = "说明要改哪里…";
@@ -507,7 +535,38 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
</div>`;
}
function leaveStepButtonHtml(spec) {
if (!spec.leaveStep) return "";
return `<button type="button" class="btn" data-act="leave_step" title="${esc(
spec.leaveStep.title || "先不写这一步,回到节点选择",
)}">${esc(spec.leaveStep.label)}</button>`;
}
function stashComposerDraftFromDom() {
if (skipComposerStash) return;
const input = $("composer-input");
if (!input) return;
const v = String(input.value ?? "");
if (v) {
pendingComposerDraft = v;
pendingComposerDraftSeq = 0;
}
}
function keepComposerDraft(text) {
const v = String(text ?? "");
if (!v) return;
pendingComposerDraft = v;
pendingComposerDraftSeq = 0;
}
function dropComposerDraft() {
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
}
function renderComposer(view, loading) {
stashComposerDraftFromDom();
if (pendingComposerDraft) composerForceInput = true;
const host = $("composer");
const root = $("composer-main") || host;
@@ -517,6 +576,13 @@ function renderComposer(view, loading) {
host._enterHandler = null;
}
const spec = resolveComposer(view, loading);
const leaveAct = view.actions?.find((a) => a.type === "leave_step");
if (leaveAct && spec.mode !== "waiting" && spec.mode !== "idle") {
spec.leaveStep = {
label: leaveAct.label || "返回节点",
title: "先不写这一步,回到节点选择",
};
}
if (spec.mode === "waiting") {
root.innerHTML = `<div class="composer-waiting">
@@ -556,7 +622,7 @@ function renderComposer(view, loading) {
if (spec.mode === "action") {
const reject = spec.showReject
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "暂不" : "不接受"}</button>
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "返回节点" : "不接受"}</button>
<button type="button" class="btn" data-act="force-input">说明意见</button>`
: "";
const chip = composerModeChip(spec);
@@ -611,6 +677,9 @@ function renderComposer(view, loading) {
: "具体写什么在步骤内确定。"
}</p>`
: "";
const closerHint = p.closer
? `<p class="propose-plan-hint">这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。</p>`
: "";
root.innerHTML = `
<div class="propose-step" id="propose-step" data-step-id="${esc(p.stepId)}">
<div class="propose-step-head">
@@ -619,11 +688,12 @@ function renderComposer(view, loading) {
</div>
<p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p>
${planHint}
${closerHint}
${fields ? `<div class="propose-fields">${fields}</div>` : ""}
${syncHint}
<div class="composer-actions">
<button type="button" class="btn btn-primary" data-act="approve">${esc(spec.primary.label)}</button>
<button type="button" class="btn" data-act="reject">暂不</button>
<button type="button" class="btn" data-act="reject" title="先不写这一步,回到节点选择">返回节点</button>
<button type="button" class="btn" data-act="force-input">改意见</button>
</div>
</div>`;
@@ -687,6 +757,17 @@ function renderComposer(view, loading) {
return;
}
if (spec.mode === "pick_step") {
const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
trailing: `<button type="submit" class="btn btn-primary composer-btn" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
});
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
wireComposerForm({ requiresText: true });
applyPendingComposerDraft();
return;
}
const acceptOnEmpty = Boolean(spec.acceptAction);
const acceptTone = spec.acceptAction?.tone || "accept";
const acceptBtn = spec.acceptAction
@@ -705,7 +786,7 @@ function renderComposer(view, loading) {
: spec.placeholder;
const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`,
trailing: `<button type="button" class="btn composer-btn${
trailing: `${leaveStepButtonHtml(spec)}<button type="button" class="btn composer-btn${
sendIsPrimary ? " btn-primary" : ""
}" data-act="revise" title="${esc(
acceptOnEmpty
@@ -716,6 +797,7 @@ function renderComposer(view, loading) {
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
wireComposerForm({ acceptOnEmpty });
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
root.querySelector("[data-act=leave_step]")?.addEventListener("click", () => runAction("leave_step"));
applyPendingComposerDraft();
}
@@ -752,6 +834,7 @@ function applyPendingComposerDraft() {
pendingComposerDraft = "";
pendingComposerDraftSeq = 0;
autosizeComposerInput(input);
syncPickReplanButton(input);
input.focus();
refreshReviewComposerChrome();
}
@@ -804,10 +887,21 @@ function syncDualComposerPrimary(input, acceptBtn, sendBtn) {
sendBtn.classList.toggle("btn-primary", revisePrimary);
}
function syncPickReplanButton(input) {
const btn = $("composer-form")?.querySelector("[data-act=pick-replan]");
if (!btn) return;
const hasText = Boolean(String(input?.value ?? "").trim());
btn.disabled = !hasText;
btn.title = hasText
? "发送改编排意见"
: "写下意见后再发;点图上节点即确认并进入";
}
function wireComposerForm(opts = {}) {
const form = $("composer-form");
const input = $("composer-input");
const acceptOnEmpty = opts.acceptOnEmpty === true;
const requiresText = opts.requiresText === true;
const sendBtn = form?.querySelector("[data-act=revise]");
const root = $("composer");
if (root?._enterHandler) {
@@ -816,12 +910,12 @@ function wireComposerForm(opts = {}) {
}
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await submitComposer(input?.value ?? "", { acceptOnEmpty });
await submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
});
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
void submitComposer(input?.value ?? "", { acceptOnEmpty });
void submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
}
});
sendBtn?.addEventListener("click", () => {
@@ -846,6 +940,7 @@ function wireComposerForm(opts = {}) {
const grow = () => {
autosizeComposerInput(input);
if (acceptOnEmpty) refreshReviewComposerChrome();
if (requiresText) syncPickReplanButton(input);
};
input?.addEventListener("input", grow);
input?.addEventListener("change", grow);
@@ -868,12 +963,6 @@ function bookPlayReady(bookId) {
return saves.some((s) => s.kind === "instance" || s.kind === "run");
}
function bookHasPlayBranch(bookId) {
if (bookPlayReady(bookId)) return true;
if (playSavesLoading.has(bookId) && !playSavesByBook.has(bookId)) return true;
return playRunsForBook(bookId).length > 0;
}
function formatSaveWhen(createdAt) {
const when = new Date(createdAt);
if (Number.isNaN(when.getTime())) return "";
@@ -900,6 +989,7 @@ async function fetchPlaySavesForBook(bookId, force = false) {
}
function setBookSelectMode(on, { keepSelection = false } = {}) {
if (on && sidebarNav.level === "book") return;
bookSelectMode = on;
if (!on) selectedBookIds = new Set();
else if (!keepSelection) selectedBookIds = new Set();
@@ -962,8 +1052,50 @@ function showBookMenu(bookId, x, y) {
}
}
function expandBook(bookId) {
if (bookId) expandedBookIds.add(bookId);
function isCatalogNav() {
return sidebarNav.level !== "book";
}
function getNavBook() {
if (sidebarNav.level !== "book") return null;
return books.find((b) => b.id === sidebarNav.bookId) ?? null;
}
function syncRailContext() {
const el = $("rail-context");
if (!el) return;
const book = getNavBook();
if (sidebarNav.level === "book" && book) {
el.textContent = book.title;
el.title = book.title;
} else {
el.textContent = "作品";
el.removeAttribute("title");
}
$("rail-tabs")?.toggleAttribute("hidden", sidebarNav.level !== "book");
}
function navigateToBook(bookId) {
if (!bookId) return;
sidebarNav = { level: "book", bookId };
document.body.dataset.navLevel = "book";
bookSelectMode = false;
selectedBookIds = new Set();
syncRailContext();
renderBookList();
void fetchPlaySavesForBook(bookId);
}
function showCatalog() {
beginUiRequest();
stopLivePoll();
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
closeWorkspace();
syncRailContext();
renderBookList();
}
function renderExplorerRow({
@@ -1025,7 +1157,7 @@ function renderExplorerRow({
return row;
}
function renderBookBranch(book, list) {
function renderBookBranch(book, list, { nested = false } = {}) {
const loading = playSavesLoading.has(book.id);
const saves = playSavesByBook.get(book.id);
const ready = bookPlayReady(book.id);
@@ -1045,7 +1177,7 @@ function renderBookBranch(book, list) {
name: "当前",
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
active: playHere && !activePlaySaveId,
child: true,
child: nested,
onClick: () => void openCurrentPlay(book.id),
}),
);
@@ -1058,7 +1190,7 @@ function renderBookBranch(book, list) {
name: s.label,
meta: when ? `游玩 · ${when}` : "游玩进度",
active: playHere && activePlaySaveId === s.id,
child: true,
child: nested,
actionClass: "explorer-file",
onClick: () => void loadPlaySave(book.id, s.id),
onDelete: () => void deletePlaySave(book.id, s.id, s.label),
@@ -1071,16 +1203,30 @@ function renderBookBranch(book, list) {
renderExplorerRow({
name: "+ 新开一局",
actionClass: "explorer-action",
child: true,
child: nested,
onClick: () => void startNewPlayForBook(book.id),
}),
);
}
}
function renderBookContents(book, list) {
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
list.appendChild(
renderExplorerRow({
name: "创作",
meta: skill,
active: book.id === activeBookId && !isPlayNow(),
onClick: () => void openBookDesign(book.id),
}),
);
renderBookBranch(book, list, { nested: false });
}
function renderBookList() {
const list = $("book-list");
if (!list) return;
syncRailContext();
if (!books.length) {
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`;
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
@@ -1088,39 +1234,36 @@ function renderBookList() {
}
list.innerHTML = "";
if (sidebarNav.level === "book") {
const book = getNavBook();
if (book) {
renderBookContents(book, list);
return;
}
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
syncRailContext();
}
for (const book of books) {
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
const selected = selectedBookIds.has(book.id);
const expanded = expandedBookIds.has(book.id);
const hasBranch = bookHasPlayBranch(book.id);
const bookActive = book.id === activeBookId && !isPlayNow() && !selected;
list.appendChild(
renderExplorerRow({
name: book.title,
meta: skill,
active: bookActive,
selected,
bookId: book.id,
actionClass: "explorer-folder",
twistie: hasBranch,
expanded,
onTwistie: () => {
if (expandedBookIds.has(book.id)) expandedBookIds.delete(book.id);
else expandedBookIds.add(book.id);
renderBookList();
if (!expandedBookIds.has(book.id)) return;
void fetchPlaySavesForBook(book.id);
},
onClick: () => {
if (bookSelectMode) {
toggleBookSelection(book.id);
return;
}
void openBookDesign(book.id);
void openBook(book.id);
},
}),
);
if (expanded && hasBranch) renderBookBranch(book, list);
}
}
@@ -1200,7 +1343,7 @@ async function deleteSelectedBooks() {
books = books.filter((b) => !deleted.has(b.id));
setBookSelectMode(false);
if (activeBookId && deleted.has(activeBookId)) {
if (books.length) await openBook(books[0].id);
if (books.length) showCatalog();
else renderEmpty();
} else {
renderBookList();
@@ -1229,9 +1372,9 @@ function setupBookBoxSelect() {
};
list.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
if (sidebarNav.level !== "root" || e.button !== 0) return;
if (e.target.closest("#book-action-menu")) return;
if (e.target.closest(".explorer-row.child")) return;
if (e.target.closest(".explorer-row")) return;
dragging = true;
start = { x: e.clientX, y: e.clientY };
overlay.hidden = true;
@@ -1282,8 +1425,8 @@ function setupBookBoxSelect() {
async function loadPlaySave(bookId, saveId) {
try {
expandBook(bookId);
if (lastView) renderSession(lastView, true);
navigateToBook(bookId);
if (lastView?.bookId === bookId) renderSession(lastView, true);
const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`,
{ method: "POST" },
@@ -1316,8 +1459,8 @@ async function startNewPlayForBook(bookId) {
return;
}
try {
expandBook(bookId);
if (lastView) renderSession(lastView, true);
navigateToBook(bookId);
if (lastView?.bookId === bookId) renderSession(lastView, true);
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" });
activePlaySaveId = null;
renderSession(data.session, false);
@@ -1328,7 +1471,7 @@ async function startNewPlayForBook(bookId) {
}
async function openBookDesign(bookId) {
expandBook(bookId);
navigateToBook(bookId);
activePlaySaveId = null;
if (activeBookId !== bookId || !sessionId) {
await openBook(bookId);
@@ -1341,7 +1484,7 @@ async function openBookDesign(bookId) {
}
async function openCurrentPlay(bookId) {
expandBook(bookId);
navigateToBook(bookId);
const alreadyHere =
activeBookId === bookId &&
isPlayNow() &&
@@ -1390,12 +1533,12 @@ function renderHeader(view, loading) {
if (btnExport) btnExport.disabled = !(view.messages?.length);
}
function renderEmpty() {
function closeWorkspace({ emptyCopy = "" } = {}) {
stopLivePoll();
sessionId = null;
lastView = null;
activeBookId = null;
activePlaySaveId = null;
setBookSelectMode(false);
composerForceInput = false;
document.title = "Writing Agent";
$("status-dot").className = "status-dot";
@@ -1406,7 +1549,7 @@ function renderEmpty() {
if (btnInst) btnInst.hidden = true;
const btnExport = $("btn-export");
if (btnExport) btnExport.disabled = true;
$("message-feed").innerHTML = `<p class="empty">点击左侧 + 新建作品</p>`;
$("message-feed").innerHTML = emptyCopy ? `<p class="empty">${emptyCopy}</p>` : "";
const stage = $("workspace-stage");
if (stage) {
stage.hidden = true;
@@ -1439,6 +1582,14 @@ function renderEmpty() {
document.body.dataset.lifecycle = "design";
const composerMain = $("composer-main");
if (composerMain) composerMain.innerHTML = `<div class="composer-idle">暂无打开的作品</div>`;
}
function renderEmpty() {
sidebarNav = { level: "root" };
document.body.dataset.navLevel = "root";
bookSelectMode = false;
selectedBookIds = new Set();
closeWorkspace({ emptyCopy: "点击左侧 + 新建作品" });
renderBookList();
}
@@ -1490,12 +1641,14 @@ function startLivePoll() {
}
function renderSession(view, loading = false) {
if (isCatalogNav()) return;
lastView = view;
sessionId = view.id;
activeBookId = view.bookId ?? activeBookId;
if (
!loading &&
view.waitingReason?.kind !== "approve_step" &&
view.waitingReason?.kind !== "pick_creation_step" &&
view.waitingReason?.kind !== "review_artifact" &&
view.waitingReason?.kind !== "worker_questions" &&
!(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length)
@@ -1524,6 +1677,12 @@ function renderSession(view, loading = false) {
onSwitchVariant: (messageId, direction) =>
messageAction("variant", messageId, { direction }),
onRetryRun: () => retryRun(),
onPickCreationStep: (stepId) => runAction("pick_step", { stepId }),
onReenterCreationStep: (stepId) =>
runAction("pick_step", { stepId, reenter: true }),
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
onLeaveCreationStep: () => runAction("leave_step"),
});
renderComposer(view, loading);
if (loading) startLivePoll();
@@ -1573,8 +1732,12 @@ function selectedQuestionAnswers(view) {
}
/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */
async function submitComposer(text, { acceptOnEmpty } = {}) {
async function submitComposer(text, { acceptOnEmpty, requiresText } = {}) {
const trimmed = String(text ?? "").trim();
if (requiresText && !trimmed) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if (acceptOnEmpty && !trimmed) {
const { answered } = selectedQuestionAnswers(lastView);
if (answered.length === 0) {
@@ -1609,7 +1772,11 @@ async function sendText(text) {
if (reviewing) {
const { collected, answered } = selectedQuestionAnswers(lastView);
if (!trimmed && answered.length === 0) {
alert("请选择追问选项或填写修改意见;满意请点「接受」收下产物。");
alert(
isFlowPlanReview(lastView)
? "点图上节点即确认并进入;要改编排请先写下意见。"
: "请选择追问选项或填写修改意见;满意请点「接受」收下产物。",
);
return;
}
try {
@@ -1681,6 +1848,13 @@ async function sendText(text) {
}
if (!trimmed) {
if (
lastView?.waitingReason?.kind === "pick_creation_step" ||
isFlowPlanReview(lastView)
) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if (
lastView?.waitingReason?.kind === "next_intent" ||
resolveComposer(lastView, false)?.allowEmpty
@@ -1789,7 +1963,6 @@ async function loadBooks() {
const data = await api("/api/books");
books = data.books ?? [];
renderBookList();
for (const book of books) void fetchPlaySavesForBook(book.id);
}
function openNewBookDialog() {
@@ -1865,7 +2038,7 @@ async function createBook() {
$("dialog-new-book").close();
if (data.book) books.unshift(data.book);
activeBookId = data.book?.id;
expandBook(data.book.id);
navigateToBook(data.book.id);
renderSession(data.session, false);
} catch (err) {
alert(err.message);
@@ -1875,19 +2048,22 @@ async function createBook() {
}
async function openBook(bookId) {
const seq = beginUiRequest();
activeBookId = bookId;
expandBook(bookId);
renderBookList();
if (lastView) renderSession(lastView, true);
navigateToBook(bookId);
if (lastView?.bookId === bookId) renderSession(lastView, true);
try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" });
if (isStaleUiRequest(seq)) return;
if (data.book) {
const i = books.findIndex((b) => b.id === bookId);
if (i >= 0) books[i] = { ...books[i], ...data.book };
}
renderSession(data.session, false);
} catch (err) {
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
if (isStaleUiRequest(seq)) return;
if (lastView?.bookId === bookId) renderSession({ ...lastView, hints: [err.message] }, false);
else alert(err.message);
}
}
@@ -1899,16 +2075,13 @@ async function deleteBookById(bookId) {
await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" });
books = books.filter((b) => b.id !== bookId);
playSavesByBook.delete(bookId);
expandedBookIds.delete(bookId);
selectedBookIds.delete(bookId);
if (activeBookId === bookId) activePlaySaveId = null;
hideBookMenu();
if (activeBookId === bookId) {
if (books.length) {
await openBook(books[0].id);
} else {
renderEmpty();
}
const wasHere = activeBookId === bookId || sidebarNav.bookId === bookId;
if (wasHere) {
if (books.length) showCatalog();
else renderEmpty();
} else {
renderBookList();
}
@@ -1952,6 +2125,21 @@ async function saveCurrentInstance() {
}
}
async function showStepArtifactDialog(stepId, name) {
if (!sessionId || !stepId) return;
try {
const data = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/step-artifact?stepId=${encodeURIComponent(stepId)}`,
);
fillStepArtifactDialog({
name: data.name || name,
content: data.content,
});
} catch (err) {
alert(err.message || "无法读取产物");
}
}
function showContextTraceDialog(messageId) {
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
const review = lastView?.reviewArtifact;
@@ -2102,7 +2290,13 @@ $("btn-save-instance")?.addEventListener("click", () => {
window.addEventListener("wa:session-updated", (e) => {
const view = e.detail;
if (view?.id) renderSession(view, false);
if (!view?.id || isCatalogNav() || !sessionId) return;
if (view.id !== sessionId) return;
renderSession(view, false);
});
window.addEventListener("wa:nav-root", () => {
showCatalog();
});
document.addEventListener("click", (e) => {
@@ -2116,7 +2310,7 @@ async function init() {
void populateDirectorSelect();
await loadBooks();
if (books.length) {
await openBook(books[0].id);
showCatalog();
} else {
renderEmpty();
openNewBookDialog();
+10 -2
View File
@@ -94,6 +94,14 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
return base;
}
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
export function isFlowPlanReview(view) {
return (
view?.waitingReason?.kind === "review_artifact" &&
view?.reviewArtifact?.workerId === "design-flow"
);
}
/**
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
@@ -131,8 +139,8 @@ export function reviewComposerCopy(workerId, opts = {}) {
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
: "同意就选定此开场;要改开场白就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
+28 -12
View File
@@ -12,25 +12,28 @@
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body data-lifecycle="design">
<body data-lifecycle="design" data-nav-level="root">
<div class="layout">
<aside class="panel panel-rail" id="panel-books">
<header class="panel-head rail-head">
<button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="展开 / 收起侧栏" aria-label="展开或收起侧栏">‹</button>
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="侧栏">
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="作品">
<div class="rail-head-row">
<button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="返回作品目录" aria-label="返回作品目录">‹</button>
<h1 class="rail-context" id="rail-context">作品</h1>
<div class="panel-head-actions rail-head-actions">
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
<span class="burst-badge" id="burst-badge" hidden></span>
</div>
</div>
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="本书侧栏" hidden>
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="本书">
<span class="rail-tab-icon" aria-hidden="true">书</span>
<span class="rail-tab-label">作品</span>
<span class="rail-tab-label">本书</span>
</button>
<button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度">
<span class="rail-tab-icon" aria-hidden="true">志</span>
<span class="rail-tab-label">调度</span>
</button>
</nav>
<div class="panel-head-actions rail-head-actions">
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
<span class="burst-badge" id="burst-badge" hidden></span>
</div>
</header>
<div class="rail-panels">
@@ -50,9 +53,7 @@
<div class="main-body">
<section class="panel-feed" id="panel-feed">
<div id="skill-picker" class="skill-picker" hidden></div>
<div id="message-feed" class="message-feed">
<p class="empty">正在打开作品…</p>
</div>
<div id="message-feed" class="message-feed"></div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div>
</section>
@@ -138,6 +139,21 @@
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
</menu>
<menu id="flow-node-menu" class="context-menu" hidden>
<button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button>
<button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">重新进入修改</button>
</menu>
<dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title">
<form method="dialog" class="context-trace-form">
<h2 id="step-artifact-title">产物</h2>
<div id="step-artifact-body" class="step-artifact-body"></div>
<div class="dialog-actions">
<button type="submit" class="btn btn-primary" value="close">关闭</button>
</div>
</form>
</dialog>
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form">
<h2 id="context-trace-title">上下文调用日志</h2>
+178
View File
@@ -0,0 +1,178 @@
/** 剥 markdown 代码围栏,便于从验收正文里取出 JSON */
export function stripCodeFences(text) {
let t = String(text || "").trim();
t = t.replace(/^```(?:json|yaml|yml)?\s*\r?\n?/i, "");
t = t.replace(/\r?\n?```\s*$/i, "");
const fenced = t.match(/```(?:json|yaml|yml)?\s*\r?\n([\s\S]*?)\r?\n```/i);
if (fenced) t = fenced[1].trim();
return t.trim();
}
/** 字符串字面量里的裸换行/制表 → 转义(模型常犯) */
export function escapeRawControlsInJsonStrings(s) {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (inString) {
if (escaped) {
out += c;
escaped = false;
continue;
}
if (c === "\\") {
out += c;
escaped = true;
continue;
}
if (c === '"') {
out += c;
inString = false;
continue;
}
if (c === "\n") {
out += "\\n";
continue;
}
if (c === "\r") {
out += "\\r";
continue;
}
if (c === "\t") {
out += "\\t";
continue;
}
out += c;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
out += c;
}
return out;
}
/**
* 只把「像 JSON 定界符」的弯引号换成 ASCII。
* 字符串内部的 “对话/强调” 必须保留,否则会把合法 JSON 撕开。
*/
export function convertStructuralSmartQuotes(s) {
const text = String(s || "");
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i];
const isOpen = c === "\u201C" || c === "\u201E" || c === "\u201F";
const isClose = c === "\u201D" || c === "\u2033" || c === "\u2036";
if (!isOpen && !isClose) {
out += c;
continue;
}
let prev = i - 1;
while (prev >= 0 && /\s/.test(text[prev])) prev -= 1;
let next = i + 1;
while (next < text.length && /\s/.test(text[next])) next += 1;
const prevCh = prev >= 0 ? text[prev] : "";
const nextCh = next < text.length ? text[next] : "";
const afterOpen = !prevCh || "{[:,[".includes(prevCh);
const beforeClose = !nextCh || ":,}]".includes(nextCh);
if (isOpen && afterOpen) out += '"';
else if (isClose && beforeClose) out += '"';
else out += c;
}
return out;
}
/** 轻度修复模型常出的尾逗号 / 裸换行;不要全局替换弯引号 */
export function softenJsonText(s) {
let t = String(s || "")
.replace(/^\uFEFF/, "")
.replace(/\u2026/g, "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
t = t.replace(/^\s*\/\/.*$/gm, "");
t = t.replace(/\/\*[\s\S]*?\*\//g, "");
t = escapeRawControlsInJsonStrings(t);
t = t.replace(/,\s*([\]}])/g, "$1");
return t;
}
/** 截断的 {… 补齐引号/括号,便于验收卡仍能出 mosaic */
export function repairTruncatedJsonObject(slice) {
let s = String(slice || "").trim();
if (!s.startsWith("{")) return s;
s = s.replace(/,\s*"[^"]*$/u, "");
s = s.replace(/,\s*$/u, "");
const opens = (s.match(/\{/g) || []).length;
const closes = (s.match(/\}/g) || []).length;
const openBrackets = (s.match(/\[/g) || []).length;
const closeBrackets = (s.match(/\]/g) || []).length;
const quoteCount = (s.match(/(?<!\\)"/g) || []).length;
if (quoteCount % 2 === 1) s += '"';
if (openBrackets > closeBrackets) s += "]".repeat(openBrackets - closeBrackets);
if (opens > closes) s += "}".repeat(opens - closes);
return s;
}
function tryParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}
/** 模型常把弯引号写进字符串;必须先按原文 parse,再做宽松修复 */
export function tryParseJsonDoc(text) {
const stripped = stripCodeFences(text);
const attempts = [
stripped,
softenJsonText(stripped),
softenJsonText(convertStructuralSmartQuotes(stripped)),
];
for (const candidate of attempts) {
const parsed = tryParse(candidate);
if (parsed != null) return parsed;
}
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
) {
const inner = tryParse(cleaned);
if (typeof inner === "string") {
const parsed = tryParse(softenJsonText(inner)) ?? tryParse(inner);
if (parsed != null) return parsed;
}
}
const objStart = cleaned.indexOf("{");
const objEnd = cleaned.lastIndexOf("}");
if (objStart >= 0 && objEnd > objStart) {
const slice = cleaned.slice(objStart, objEnd + 1);
const parsed =
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(cleaned.slice(objStart))));
if (parsed != null) return parsed;
const rawSlice = stripped.slice(
stripped.indexOf("{"),
stripped.lastIndexOf("}") + 1,
);
const fromRaw = tryParse(rawSlice) ?? tryParse(repairTruncatedJsonObject(rawSlice));
if (fromRaw != null) return fromRaw;
}
const arrStart = cleaned.indexOf("[");
const arrEnd = cleaned.lastIndexOf("]");
if (arrStart >= 0 && arrEnd > arrStart) {
const parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1));
if (parsed != null) return parsed;
}
return null;
}
+47 -2
View File
@@ -432,6 +432,37 @@ code {
color: var(--st-danger);
}
.cap-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 4px;
}
.cap-unknown {
color: var(--muted, #888);
}
.cap-chip {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border, #ddd);
}
.cap-chip.cap-ok {
color: #067647;
background: #ecfdf3;
border-color: #abefc6;
}
.cap-chip.cap-fail {
color: #b42318;
background: #fef3f2;
border-color: #fecdca;
}
.cap-chip.cap-unk {
color: #667085;
background: #f9fafb;
}
/* —— Toast —— */
.settings-toast {
margin: 10px 28px 0;
@@ -1042,7 +1073,8 @@ code {
font-weight: 500;
}
.profile-dialog input {
.profile-dialog input,
.profile-dialog select {
padding: 9px 12px;
border-radius: 6px;
border: 1px solid var(--st-border-strong);
@@ -1051,12 +1083,25 @@ code {
font-size: 14px;
}
.profile-dialog input:focus {
.profile-dialog select {
cursor: pointer;
}
.profile-dialog input:focus,
.profile-dialog select:focus {
outline: none;
border-color: #93c5fd;
box-shadow: 0 0 0 3px var(--st-accent-soft);
}
.profile-dialog .field-hint {
margin-top: 2px;
font-size: 11px;
font-weight: 400;
color: var(--st-muted);
line-height: 1.35;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
+11
View File
@@ -148,6 +148,17 @@
Model
<input name="model" required placeholder="deepseek-chat" />
</label>
<label>
思考强度
<select name="reasoningEffort">
<option value="">默认(不发送)</option>
<option value="low">low · 轻量</option>
<option value="medium">medium</option>
<option value="high">high · 增强</option>
<option value="max">max · 深度</option>
</select>
<span class="field-hint">写入 reasoning_effort;「探测」始终用 low,与此项无关。GLM 建议 low / high / max。</span>
</label>
<div class="dialog-actions">
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
<button type="button" id="profile-save" class="btn-secondary">仅保存</button>
+53 -7
View File
@@ -150,6 +150,31 @@ function activateButtonHtml(active, id, action) {
return `<button type="button" class="btn-activate" data-action="${action}" data-id="${id}">选用并生效</button>`;
}
function capabilityBadge(caps) {
if (!caps || !caps.testedAt) {
return `<div class="config-card-meta cap-unknown">能力:未探测(选用前建议点「探测」)</div>`;
}
const bit = (label, status) => {
const cls =
status === "ok" ? "cap-ok" : status === "fail" ? "cap-fail" : "cap-unk";
return `<span class="cap-chip ${cls}">${escapeHtml(label)}:${escapeHtml(status)}</span>`;
};
const mode =
caps.jsonSchema === "ok"
? "JSON Schema"
: caps.forcedTool === "ok"
? "强制 Tool"
: caps.jsonObject === "ok"
? "JSON Object"
: "纯文本";
return `<div class="config-card-meta cap-row">
投递:<strong>${escapeHtml(mode)}</strong>
${bit("schema", caps.jsonSchema)}
${bit("tool", caps.forcedTool)}
${bit("json", caps.jsonObject)}
</div>`;
}
function renderProfiles() {
profilesListEl.innerHTML = "";
if (!state.profiles.length) {
@@ -171,13 +196,18 @@ function renderProfiles() {
${active ? '<span class="badge">当前</span>' : ""}
</div>
<div class="config-card-meta">${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}</div>
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}</div>
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}${
p.reasoningEffort
? ` · 思考: ${escapeHtml(p.reasoningEffort)}`
: ""
}</div>
${capabilityBadge(p.capabilities)}
</div>
</div>
<div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-profile")}
<button type="button" data-action="edit-profile" data-id="${p.id}">编辑</button>
<button type="button" data-action="test-profile" data-id="${p.id}">测试连接</button>
<button type="button" data-action="probe-capabilities" data-id="${p.id}">探测</button>
<button type="button" class="btn-danger" data-action="delete-profile" data-id="${p.id}">删除</button>
</div>
<div class="test-result" id="test-${p.id}"></div>`;
@@ -367,6 +397,7 @@ function openProfileDialog(profile = null) {
profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com";
profileForm.apiKey.value = profile?.apiKey ?? "";
profileForm.model.value = profile?.model ?? "deepseek-v4-pro";
profileForm.reasoningEffort.value = profile?.reasoningEffort ?? "";
profileDialog.showModal();
}
@@ -375,6 +406,7 @@ async function saveProfile(activate) {
name: profileForm.name.value,
baseUrl: profileForm.baseUrl.value,
model: profileForm.model.value,
reasoningEffort: profileForm.reasoningEffort.value,
activate,
};
const apiKey = profileForm.apiKey.value.trim();
@@ -457,13 +489,27 @@ profilesListEl.addEventListener("click", async (e) => {
await activateProfile(id);
} else if (action === "edit-profile") {
openProfileDialog(state.profiles.find((p) => p.id === id));
} else if (action === "test-profile") {
} else if (action === "probe-capabilities") {
const el = document.getElementById(`test-${id}`);
el.textContent = "测试中…";
el.textContent = "探测中(含连通性,会打几次短请求)…";
el.className = "test-result";
const result = await api(`/api/profiles/${id}/test`, { method: "POST" });
el.textContent = result.message;
el.className = `test-result ${result.ok ? "ok" : "fail"}`;
const result = await api(`/api/profiles/${id}/probe-capabilities`, {
method: "POST",
});
await loadAll();
const caps = result.profile?.capabilities;
const chatOk = caps?.chat === "ok";
const note = !chatOk
? `连接失败${caps?.notes?.[0] ? `:${caps.notes[0]}` : ""}`
: result.deliveryModeLabel
? `连接正常 · 投递将用:${result.deliveryModeLabel}`
: "探测完成";
const refreshed = document.getElementById(`test-${id}`);
if (refreshed) {
refreshed.textContent = note;
refreshed.className = `test-result ${chatOk ? "ok" : "fail"}`;
}
showToast(note, !chatOk);
} else if (action === "delete-profile") {
if (!confirm("确定删除此 API 配置?")) return;
await api(`/api/profiles/${id}`, { method: "DELETE" });
+736 -29
View File
@@ -69,12 +69,35 @@ a.link:hover { text-decoration: underline; }
flex-shrink: 0;
}
.rail-head {
gap: 4px;
padding: 6px;
flex-wrap: nowrap;
align-items: center;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 0;
padding: 0;
min-width: 0;
}
.rail-head-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
min-height: 40px;
padding: 6px 8px 6px 6px;
}
body[data-nav-level="root"] .rail-head-row {
padding-left: 10px;
}
.rail-context {
flex: 1 1 auto;
min-width: 0;
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-toggle {
flex-shrink: 0;
width: 28px;
@@ -87,12 +110,19 @@ a.link:hover { text-decoration: underline; }
line-height: 1;
}
body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
body[data-nav-level="root"] .rail-toggle { display: none; }
body[data-nav-level="book"] .rail-head-actions #btn-new-book,
body[data-nav-level="root"] .burst-badge { display: none; }
.rail-tabs {
display: flex;
flex: 1 1 auto;
gap: 2px;
display: none;
gap: 0;
min-width: 0;
padding: 0 8px;
}
body[data-nav-level="book"] .rail-tabs {
display: flex;
}
.rail-tabs[hidden] { display: none !important; }
.rail-tab {
flex: 1 1 0;
display: inline-flex;
@@ -102,21 +132,28 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
min-width: 0;
height: 28px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
border-radius: 6px;
border-radius: 0;
padding: 0 6px;
cursor: pointer;
color: var(--muted);
font-size: 12px;
font-weight: 600;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.rail-tab:hover { background: var(--bg); color: var(--text); }
.rail-tab:hover { color: var(--text); }
.rail-tab.active {
background: var(--accent-soft);
color: var(--accent);
background: transparent;
color: var(--text);
font-weight: 600;
border-bottom-color: var(--text);
}
.rail-tab:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: -2px;
}
.rail-tab-icon {
display: none;
@@ -177,8 +214,8 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
.rail-tab-panel[hidden] { display: none !important; }
body.rail-collapsed .rail-tab-label,
body.rail-collapsed .path-bar,
body.rail-collapsed .rail-head-actions #btn-new-book,
body.rail-collapsed .rail-context,
body.rail-collapsed .rail-head-actions,
body.rail-collapsed .burst-badge { display: none !important; }
body.rail-collapsed .rail-tab-icon { display: block; }
body.rail-collapsed .rail-head {
@@ -187,14 +224,27 @@ body.rail-collapsed .rail-head {
padding: 6px 4px;
gap: 4px;
}
body.rail-collapsed .rail-tabs {
body.rail-collapsed .rail-head-row {
justify-content: center;
padding: 0;
min-height: 0;
}
body.rail-collapsed[data-nav-level="book"] .rail-tabs:not([hidden]) {
display: flex;
flex-direction: column;
padding: 0;
}
body.rail-collapsed .rail-tab {
flex: 0 0 auto;
width: 100%;
height: 32px;
padding: 0;
border-bottom: none;
border-radius: 6px;
}
body.rail-collapsed .rail-tab.active {
background: var(--accent-soft);
color: var(--accent);
}
body.rail-collapsed .rail-panels { display: none; }
body.rail-collapsed .panel-rail { align-items: stretch; }
@@ -734,6 +784,39 @@ body.design-workspace .review-worker-set {
color: var(--accent);
margin-bottom: 8px;
}
.workspace-intent-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
max-width: 46rem;
}
.workspace-intent-head .workspace-surface-kicker {
margin-bottom: 0;
}
.workspace-leave-step {
appearance: none;
border: none;
background: transparent;
padding: 0;
font-size: 13px;
font-weight: 500;
color: var(--muted);
cursor: pointer;
font-family: var(--font-ui);
flex-shrink: 0;
}
.workspace-leave-step:hover {
color: var(--text);
}
.workspace-leave-step:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.workspace-answer-head--slim .workspace-leave-step {
margin-left: auto;
}
.workspace-intent,
.workspace-answer {
flex: 1 1 auto;
@@ -884,6 +967,406 @@ body.design-workspace .flow-status {
font-size: 12px;
color: var(--muted);
}
body.design-workspace .flow-dag {
position: relative;
overflow: auto;
padding: 4px 2px 8px;
}
body.design-workspace .flow-dag-edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: visible;
}
body.design-workspace .flow-dag-edge {
stroke: color-mix(in srgb, var(--border) 80%, var(--muted));
stroke-width: 1.5;
opacity: 0.85;
}
body.design-workspace .flow-dag-edge.is-lit {
stroke: color-mix(in srgb, var(--accent) 55%, var(--border));
opacity: 1;
}
body.design-workspace .flow-dag-arrowhead {
fill: color-mix(in srgb, var(--accent) 50%, var(--border));
}
body.design-workspace .flow-dag-rows {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 28px;
min-width: 0;
}
body.design-workspace .flow-dag-row {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
body.design-workspace .flow-dag-row-label {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--muted);
text-align: center;
}
body.design-workspace .flow-dag-row-nodes {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
body.design-workspace .flow-node {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
width: min(100%, 280px);
margin: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
color: inherit;
text-align: left;
font: inherit;
min-width: 0;
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 70%, transparent);
transition:
transform 0.12s ease,
box-shadow 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
}
body.design-workspace .flow-node-main {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
flex: 1;
}
body.design-workspace .flow-node.is-done-menu {
cursor: context-menu;
opacity: 0.92;
}
body.design-workspace .flow-node.is-done-menu:hover {
opacity: 1;
border-color: color-mix(in srgb, var(--border) 40%, var(--text) 20%);
background: var(--surface);
}
body.design-workspace .flow-node.is-done-menu:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.design-workspace .flow-node-menu-btn {
flex: 0 0 auto;
align-self: center;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
color: var(--muted);
opacity: 0.55;
pointer-events: none;
}
body.design-workspace .flow-node.is-done-menu:hover .flow-node-menu-btn,
body.design-workspace .flow-node.is-done-menu:focus-visible .flow-node-menu-btn {
opacity: 1;
}
.step-artifact-body {
max-height: min(70vh, 560px);
overflow: auto;
margin: 8px 0 0;
}
body.design-workspace .flow-node[data-run="current"] {
border-color: var(--accent);
background: var(--surface);
}
body.design-workspace .flow-node.is-locked {
opacity: 0.55;
}
body.design-workspace .flow-node.is-selectable {
cursor: pointer;
border-color: var(--accent);
border-width: 1.5px;
background: var(--surface);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
0 4px 12px color-mix(in srgb, var(--accent) 12%, transparent);
}
body.design-workspace .flow-node.is-selectable:hover {
transform: translateY(-2px);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent);
}
body.design-workspace .flow-node.is-selectable:active {
transform: translateY(0) scale(0.98);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
0 2px 6px color-mix(in srgb, var(--accent) 12%, transparent);
}
body.design-workspace .flow-node.is-selectable:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.design-workspace .flow-action {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--accent);
pointer-events: none;
}
body.design-workspace .flow-node.is-selectable:hover .flow-action {
color: color-mix(in srgb, var(--accent) 80%, #000);
}
body.design-workspace .flow-node--prototype {
border-style: dashed;
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 6%, var(--bg));
}
body.design-workspace .flow-node--prototype.is-selectable {
border-style: dashed;
border-color: var(--accent);
}
body.design-workspace .flow-node--prototype.is-selectable:hover {
border-color: var(--accent);
}
body.design-workspace .flow-occ-prototype {
color: var(--accent);
}
body.design-workspace .flow-family {
display: flex;
flex-direction: column;
align-items: stretch;
width: min(100%, 280px);
min-width: 0;
}
body.design-workspace .flow-family > .flow-node {
width: 100%;
}
body.design-workspace .flow-family-spawns {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 8px 0 0 22px;
list-style: none;
}
body.design-workspace .flow-family-spawns::before {
content: "";
position: absolute;
left: 12px;
top: 0;
bottom: 14px;
width: 1px;
background: color-mix(in srgb, var(--accent) 32%, var(--border));
}
body.design-workspace .flow-family-spawn {
position: relative;
min-width: 0;
}
body.design-workspace .flow-family-spawn::before {
content: "";
position: absolute;
left: -10px;
top: 50%;
width: 10px;
height: 1px;
background: color-mix(in srgb, var(--accent) 32%, var(--border));
}
body.design-workspace .flow-node--spawn {
width: 100%;
min-width: 0;
padding: 8px 10px;
gap: 8px;
border-radius: 8px;
box-shadow: none;
background: var(--surface);
}
body.design-workspace .flow-node--spawn .flow-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
body.design-workspace .flow-node--spawn .flow-params,
body.design-workspace .flow-node--spawn .flow-params-missing {
font-size: 11px;
}
body.design-workspace .flow-node--spawn.is-selectable {
border-width: 1px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent);
}
body.design-workspace .flow-node--spawn.is-selectable:hover {
transform: none;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
body.design-workspace .flow-spawn-meta {
flex-shrink: 0;
display: inline-flex;
align-items: center;
}
body.design-workspace .flow-spawn-meta .flow-action {
min-height: 24px;
padding: 0 8px;
font-size: 11px;
}
body.design-workspace .flow-spawn-count {
font-size: 11px;
color: var(--muted);
line-height: 1.3;
}
body.design-workspace .flow-origin {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--muted);
line-height: 1.3;
}
body.design-workspace .flow-origin-rule {
font-weight: 650;
color: var(--text);
}
body.design-workspace .flow-dag-edge--belong {
stroke-dasharray: 3 4;
stroke-width: 1.25;
}
body.design-workspace .flow-dag-arrowhead--belong {
fill: color-mix(in srgb, var(--accent) 38%, var(--border));
}
body.design-workspace .flow-suggestion-inline {
font-size: 12px;
color: var(--muted);
line-height: 1.35;
}
body.design-workspace .flow-suggestions {
margin-top: 12px;
padding: 10px 12px;
border: 1px dashed var(--border);
border-radius: 8px;
background: var(--surface);
}
body.design-workspace .flow-suggestions-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
color: var(--muted);
}
body.design-workspace .flow-suggestion {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
body.design-workspace .flow-suggestion:first-of-type {
border-top: none;
padding-top: 0;
}
body.design-workspace .flow-suggestion-name {
font-weight: 600;
}
body.design-workspace .flow-suggestion-text {
color: var(--muted);
}
body.design-workspace .flow-node .flow-run {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--muted);
}
body.design-workspace .flow-node[data-run="done"] .flow-run {
color: #15803d;
}
body.design-workspace .flow-node.is-selectable .flow-run {
color: var(--accent);
}
body.design-workspace .flow-node .flow-name {
font-weight: 600;
font-size: 14px;
line-height: 1.35;
}
body.design-workspace .flow-title {
font-size: 13px;
font-weight: 650;
line-height: 1.35;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
body.design-workspace .flow-node .flow-params,
body.design-workspace .flow-node .flow-params-missing {
font-size: 12px;
color: var(--muted);
line-height: 1.35;
}
body.design-workspace .flow-spawns {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
body.design-workspace .flow-spawn {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
border: 1px dashed var(--border);
border-radius: 8px;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
}
body.design-workspace .flow-spawn.is-ready {
cursor: pointer;
border-style: solid;
border-color: var(--accent);
color: var(--text);
}
body.design-workspace .flow-spawn.is-ready:hover {
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
body.design-workspace .flow-spawn.is-ready:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body.design-workspace .flow-spawn.is-blocked {
opacity: 0.65;
}
@media (max-width: 720px) {
body.design-workspace .flow-dag-row-nodes {
flex-direction: column;
align-items: stretch;
}
body.design-workspace .flow-node,
body.design-workspace .flow-family {
width: 100%;
}
}
body.design-workspace .flow-steps {
list-style: none;
margin: 0;
@@ -1004,8 +1487,20 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
cursor: pointer;
padding: 6px 10px;
}
.btn:focus,
.btn-sm:focus {
outline: none;
}
.btn:focus-visible,
.btn-sm:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn-sm { padding: 4px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:focus-visible {
outline-color: color-mix(in srgb, var(--accent) 50%, #fff);
}
.btn-danger { color: var(--danger); border-color: #fecaca; }
.btn:disabled, .btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -1950,7 +2445,7 @@ body.design-workspace .af-mosaic {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}
/* 美学验收:左契约 / 右诊断(整体样式约定) */
/* 美学验收:左契约 / 右诊断 */
.af-split {
display: flex;
flex-direction: column;
@@ -2002,13 +2497,13 @@ body.design-workspace .af-mosaic {
background: var(--bg);
}
.af-block-head {
margin-bottom: 10px;
margin-bottom: 8px;
}
.af-block-title {
margin: 0 0 4px;
margin: 0;
font-family: var(--font-ui);
font-size: 15px;
font-weight: 700;
font-size: 14px;
font-weight: 650;
color: var(--text);
letter-spacing: 0.01em;
display: flex;
@@ -2032,20 +2527,80 @@ body.design-workspace .af-mosaic {
.af-block-body {
min-width: 0;
}
.af-block--product .af-block-body .artifact-kernel .artifact-prose,
.af-block--product .af-block-body .artifact-prose {
font-family: var(--font-read);
font-size: 15.5px;
line-height: 1.75;
max-width: 42rem;
/* 美学卡:阅读层级 — 内核最大,范式次之,右栏更密更沉 */
.af-split--aesthetics .af-split-product {
gap: 22px;
}
.af-block--eval .af-block-title {
.af-split--aesthetics .af-block--product .af-block-title {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: none;
letter-spacing: 0.02em;
}
.af-split--aesthetics .af-block--product .af-block-head {
margin-bottom: 6px;
}
.af-split--aesthetics .artifact-kernel.qcard-assessment,
.af-split--aesthetics .artifact-kernel {
margin: 0 0 14px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
background: color-mix(in srgb, var(--surface) 88%, var(--bg));
}
.af-split--aesthetics .artifact-kernel .artifact-aside-label {
margin: 0 0 6px;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.04em;
color: var(--muted);
}
.af-split--aesthetics .artifact-kernel .artifact-prose,
.af-split--aesthetics .artifact-kernel .artifact-inline {
font-family: var(--font-read);
font-size: 16px;
line-height: 1.75;
max-width: 40rem;
}
.af-split--aesthetics .af-block--product .af-group-title,
.af-split--aesthetics .af-block--product .af-section-title {
font-size: 12px;
font-weight: 650;
color: var(--muted);
margin: 0 0 6px;
}
.af-split--aesthetics .af-block--product .af-stack,
.af-split--aesthetics .af-block--product .af-def-list {
gap: 12px;
}
.af-split--aesthetics .af-block--product .af-def-v,
.af-split--aesthetics .af-block--product .artifact-prose {
font-family: var(--font-read);
font-size: 14.5px;
line-height: 1.65;
max-width: 40rem;
}
.af-split--aesthetics .af-split-eval {
gap: 12px;
font-size: 13px;
}
.af-block--extra .af-block-title {
.af-split--aesthetics .af-block--eval .af-block-title,
.af-split--aesthetics .af-block--extra .af-block-title {
font-size: 12px;
font-weight: 650;
color: var(--muted);
}
.af-split--aesthetics .af-block--eval .af-block-body,
.af-split--aesthetics .af-block--extra .af-block-body {
font-size: 13px;
line-height: 1.5;
color: color-mix(in srgb, var(--text) 92%, var(--muted));
}
.af-split--aesthetics .af-dim-title {
font-size: 12px;
}
@media (max-width: 900px) {
.af-split-grid {
grid-template-columns: 1fr;
@@ -2368,6 +2923,145 @@ body.design-workspace .af-mosaic {
flex-direction: column;
gap: 14px;
}
/* 美学纲领:主栏契约 / 辅栏诊断(无第二层盒子) */
.skill-view.skill-view-aesthetics {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(15.5rem, 0.88fr);
gap: 8px 36px;
align-items: start;
}
.skill-view-aesthetics.af-aes--solo {
grid-template-columns: 1fr;
}
.af-aes-main,
.af-aes-diag {
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.af-aes-diag {
padding-left: 22px;
border-left: 1px solid var(--border);
}
.af-aes-kernel .artifact-aside-label {
display: block;
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--muted);
}
.af-aes-kernel .artifact-prose,
.af-aes-kernel .artifact-inline {
font-family: var(--font-read);
font-size: 15.5px;
line-height: 1.78;
max-width: 42rem;
margin: 0;
color: var(--text);
}
.af-aes-paradigm {
display: flex;
flex-direction: column;
gap: 14px;
}
.af-aes-main > .skill-card-section > h4,
.af-aes-paradigm > .skill-card-section > h4 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted);
}
.af-aes-diag > .skill-card-section > h4 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
}
.af-aes-diag .af-dim-stack { gap: 10px; }
.af-aes-diag .af-dim { padding-bottom: 10px; }
.af-aes-diag .af-dim-title { font-size: 13px; }
.af-aes-twist {
display: flex;
flex-direction: column;
gap: 8px;
}
.af-aes-twist-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
gap: 8px 10px;
align-items: start;
}
.af-aes-twist-k {
font-size: 11px;
font-weight: 600;
color: var(--muted);
padding-top: 2px;
}
.af-aes-twist-v {
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
.af-aes-twist-infer {
margin: 2px 0 0;
font-family: var(--font-read);
font-size: 13.5px;
line-height: 1.55;
color: var(--text);
}
.af-dim-row.is-pending .af-dim-k {
color: #a16207;
font-weight: 650;
}
.af-dim-row.is-pending .af-dim-v { color: #854d0e; }
@media (max-width: 860px) {
.skill-view.skill-view-aesthetics {
grid-template-columns: 1fr;
gap: 20px;
}
.af-aes-diag {
padding-left: 0;
border-left: none;
padding-top: 16px;
border-top: 1px solid var(--border);
}
}
.artifact-compact-body .skill-view.skill-view-aesthetics {
grid-template-columns: 1fr;
gap: 16px;
}
.artifact-compact-body .af-aes-diag {
padding-left: 0;
border-left: none;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.ws-focus-body .skill-view.skill-view-aesthetics {
grid-template-columns: minmax(0, 1.55fr) minmax(15.5rem, 0.88fr);
}
.ws-focus-body .af-aes-diag {
padding-left: 22px;
border-left: 1px solid var(--border);
padding-top: 0;
border-top: none;
}
@media (max-width: 860px) {
.ws-focus-body .skill-view.skill-view-aesthetics {
grid-template-columns: 1fr;
}
.ws-focus-body .af-aes-diag {
padding-left: 0;
border-left: none;
padding-top: 16px;
border-top: 1px solid var(--border);
}
}
.skill-card-section > h4 {
margin: 0 0 8px;
font-size: 13px;
@@ -2395,6 +3089,14 @@ body.design-workspace .af-mosaic {
font-size: 13px;
line-height: 1.45;
}
.af-aes-main .skill-kv-row,
.af-aes-paradigm .skill-kv-row {
grid-template-columns: minmax(4.75rem, 7.25rem) 1fr;
}
.af-aes-main .skill-kv-val,
.af-aes-paradigm .skill-kv-val {
line-height: 1.55;
}
@media (max-width: 640px) {
.skill-kv-row { grid-template-columns: 1fr; gap: 2px; }
}
@@ -3688,6 +4390,11 @@ body.design-workspace .af-mosaic {
}
.context-menu-item[hidden] { display: none; }
.context-menu-item:hover { background: var(--bg); }
.context-menu-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.context-menu-item:disabled:hover { background: transparent; }
.context-menu-separator-before { border-top: 1px solid var(--border); }
.context-menu-danger { color: var(--danger); }
.context-menu-danger:hover { background: #fef2f2; }
@@ -3976,7 +4683,7 @@ body.design-workspace .af-mosaic {
.layout { grid-template-columns: var(--books-w-collapsed) 1fr; }
body:not(.rail-collapsed) { --books-w: var(--books-w-collapsed); }
body:not(.rail-collapsed) .rail-tab-label,
body:not(.rail-collapsed) .path-bar { display: none !important; }
body:not(.rail-collapsed) .rail-context { display: none !important; }
body:not(.rail-collapsed) .rail-panels { display: none; }
}