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` | | 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` | | 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
+2 -5
View File
@@ -121,8 +121,7 @@
"steps": [ "steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] }, { "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }, { "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
{ "id": "美学纲领与交互范式·改", "name": "美学纲领与交互范式", "mode": "revise", "revises": "美学纲领与交互范式", "depends_on": ["生成规则#2"] }
] ]
} }
``` ```
@@ -134,12 +133,10 @@
| `id` | 本局步骤唯一键;同技能多次必须不同 | | `id` | 本局步骤唯一键;同技能多次必须不同 |
| `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) | | `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) |
| `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) | | `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) |
| `mode` | 可选。缺省 / `fresh` = 从零新建;`revise` = 回头修改,继承 `revises` 所指那步的既有产物 |
| `revises` | `mode=revise` 时必填:被改的既有步骤 id(须更前、同 name) |
| `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) | | `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) |
程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。 程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改用 `mode=revise`,不要再排一条 fresh。 **禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。
〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。 〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。
## 2.5 已合并技能(勿拆回) ## 2.5 已合并技能(勿拆回)
+8 -4
View File
@@ -29,9 +29,13 @@
推荐游玩调度:`gm → perspective? → narrator → auditor`。 推荐游玩调度:`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。 **存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。 实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。 舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。 生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段 + **数量**与**一次生成**(单条|同批,同批须钉记录间关系));自评=必要性/属性妥当/格式准确。
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。 美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。 叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。 正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
@@ -65,7 +69,7 @@
开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。 开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。
具体实例正文:`rule_id` + `records[]`(单层,键随规则);自评=合规模(只按规则执行)。 具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。
其它技能换掉正文内块名与自评维度名即可,三段外壳不要拆。 其它技能换掉正文内块名与自评维度名即可,三段外壳不要拆。
## 4. 投影排序:`context-order.v1`(扁平序) ## 4. 投影排序:`context-order.v1`(扁平序)
+1 -1
View File
@@ -36,7 +36,7 @@ Book 跨 Session:过程、资产、游玩
新建作品 → UI 选配方 → 用户首句 新建作品 → UI 选配方 → 用户首句
→ design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed) → design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed)
→ 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物 → 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**;已完成节点用回头修改继承旧稿)→ closed → 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**)→ closed
→ 收成 设计.worker集(运行规格)→ 验收后才可进 play → 收成 设计.worker集(运行规格)→ 验收后才可进 play
→ 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance → 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance
→ (无开场白节点时可选)opening-generator 兜底落库 → (无开场白节点时可选)opening-generator 兜底落库
+6 -3
View File
@@ -35,7 +35,7 @@
**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。 **一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改用**回头修改**(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。 **调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改:在图上点该节点重进(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
**剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成: **剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成:
- **工作流计划** = 怎么排技能步骤(DAG) - **工作流计划** = 怎么排技能步骤(DAG)
@@ -181,12 +181,15 @@
|----------------------|----------------|----------| |----------------------|----------------|----------|
| `intake` | 描述需求 | 发送 | | `intake` | 描述需求 | 发送 |
| `input` | 补充说明 | 发送 / 补充并发送 | | `input` | 补充说明 | 发送 / 补充并发送 |
| `worker_questions` | 答题 | 补充并发送 | | `worker_questions` | 答题 | 补充并发送;已进技能步时旁挂「返回节点」 |
| `approve_step` | 确认 | 同意并开始;回头修改时文案为「回头修改 · {技能名}」 | | `approve_step` | 确认 | 同意并开始;重进已完成节点时文案为「回头修改 · {技能名}」;次要为「返回节点」 |
| `pick_creation_step` | 选节点 | 点可进入的节点即开始;要改排在底栏写意见再发 |
| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 | | `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
| `next_intent` | 说话 | 继续(可留空) | | `next_intent` | 说话 | 继续(可留空) |
| `revision` | 说话 | 按意见修改 | | `revision` | 说话 | 按意见修改 |
**返回节点**:已点进某步(尤其可增殖原型刚增殖出的实例)但还没写完时,退回分层图。未验收的空实例从图上撤掉,避免误点留下一条空槽。已完成节点重进后返回则保留该节点。勿写成「取消作品」或浏览器后退。
--- ---
## 8. 禁止出现在用户主路径上的写法 ## 8. 禁止出现在用户主路径上的写法
+5 -5
View File
@@ -127,7 +127,7 @@ declaration: …
→ 循环直到「接受」 → 循环直到「接受」
→ 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行) → 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行)
· 可反复技能再来一次 = 彻底新建(新 id / 新 params) · 可反复技能再来一次 = 彻底新建(新 id / 新 params)
· 已完成节点要改 = 回头修改(mode=revise,继承旧产物) · 已完成节点要改 = 用户在图上点该节点(查看产物 / 重新进入修改),不在 DAG 里加修订步
→ 下一节点 opening / design-step … → 下一节点 opening / design-step …
直到「开场白与开场变量」(〔收口〕) 直到「开场白与开场变量」(〔收口〕)
→ 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿 → 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿
@@ -135,7 +135,7 @@ declaration: …
「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。 「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。
「回头修改」是编排层能力:给已完成步骤追加一条 `mode=revise` 的新步,执行时注入既有产物继续改。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,用回头修改,不必整局重开。 「回头修改」是已完成节点上的动作,不是 DAG 节点:用户点该格,菜单里回看产物或重进继承旧稿。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,点那个已完成节点即可,不必整局重开。
〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。 〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。
@@ -188,6 +188,6 @@ recipes/world-simulator|expand-assistant/recipe.yaml
1. 只选配方 → 出**近期**创作流程(`status=open`) 1. 只选配方 → 出**近期**创作流程(`status=open`)
2. design-step 能切割出 `opening`/`task`/… 2. design-step 能切割出 `opening`/`task`/…
3. 有 `opening` 时先程序开场再 LLM 3. 有 `opening` 时先程序开场再 LLM
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);也可 `mode=revise` 回头修改已完成节点(继承旧产物);收成前 `status=closed` 4. 可追加同能力多次(不同 step.id + params,**彻底新建**);已完成节点由用户在图上点选重进(继承旧产物);收成前 `status=closed`
5. UI 用编排术语;有编排参数的步骤须展示 params;回头修改在流程上标「回头修改」,确认开干文案与「接下来生成」区分 5. UI 用编排术语;有编排参数的步骤须展示 params;已完成节点菜单区分「查看产物」与「重新进入修改」
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;回头修改可继承原步 params 6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;重进已完成节点可继承原步 params
+1 -1
View File
@@ -25,7 +25,7 @@ worker-templates/
```text ```text
选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open) 选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open)
→ 反复 design-step(注入 modules/{id}/prompt.md) → 反复 design-step(注入 modules/{id}/prompt.md)
→ 不够则再 design-flow(可追加 repeatable 新建,或 mode=revise 回头修改)→ closed → 不够则再 design-flow(可追加 repeatable 新建)→ closed
→ (可选)opening-generator → 手动进 play → (可选)opening-generator → 手动进 play
``` ```
@@ -51,7 +51,8 @@ feeds: design_only
与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。 与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。
产物必须是 **context-fragment.v1** JSON,写入 tag「设计.美学纲领与交互范式」。 产物必须是 **context-fragment.v1** JSON,写入 tag「设计.美学纲领与交互范式」。
业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。 业务字段全部放在「正文」内(三柱为**对象**,不要写成一段散文);须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
由运行时按模型能力做 schema/tool 投递,不要再口头包一层 outputs 字符串。
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。 一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。 用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
@@ -193,17 +194,18 @@ feeds: design_only
硬规则(公共三段 + 本技正文): 硬规则(公共三段 + 本技正文):
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。 2. **公共三段**:`正文`=产物主体;`自评`=打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。 3. **正文深度**:三柱(设定逻辑 / 交互范式 / 美学纲领)为对象。柱下到「条目」为止:条目是短字段、string[],或诊断三元组 `{结论, 依据, 完备度}` / `{已知, 待探, 完备度}`。诊断里的结论/依据/已知/待探必须是字符串。禁止把整柱写成一段 text;禁止在诊断或短字段里再套对象。
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。 4. **本技三柱**:`设定逻辑`(变造 + 参与方式 + 内容维度 + 区域/复杂性/完备度)+ `交互范式`(前置配置/视角/权限/后果)+ `美学纲领`(体验内核 + 呈现要点)。未知省略或「未定」,禁止编造百科。
5. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。 5. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
6. summary:`美学纲领与交互范式 · {brief 缩略}`。 6. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
7. summary:`美学纲领与交互范式 · {brief 缩略}`。
## checklist ## checklist
```checklist ```checklist
- [ ] 含 schema / brief / 正文 / 自评 / 追问 三段外壳? - [ ] 含 schema / brief / 正文 / 自评 / 追问 三段外壳?
- [ ] 正文含设定逻辑 + 交互范式 + 美学纲领?自评有交互/美学/协调维度? - [ ] 正文三柱是对象(非一段散文)?设定逻辑里诊断有结论/依据/完备度?自评有交互/美学/协调维度?
- [ ] 未写 order、未输出执行单元列表? - [ ] 未写 order、未输出执行单元列表?
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定? - [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
- [ ] 交互范式权限与美学纲领互相保护、不矛盾? - [ ] 交互范式权限与美学纲领互相保护、不矛盾?
@@ -215,11 +217,12 @@ feeds: design_only
```examples ```examples
好: 好:
- brief 点题「垄断生存资源的绝对掌控感」;正文三块齐(设定逻辑含完备度/待探、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。 - brief 点题「垄断生存资源的绝对掌控感」;正文三柱为对象(设定逻辑含诊断三元组、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳。 - 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳,也不要把三柱收成一段 text。
坏: 坏:
- 只有散文纲领,无 JSON / 无自评 / 无追问结构 - 只有散文纲领,无 JSON / 无自评 / 无追问结构
- 三柱是 sections[].text 一段话,验收卡无法切开诊断与契约
- 百科式背景堆砌,无满足来源与权限边界 - 百科式背景堆砌,无满足来源与权限边界
- 输出 workers[] 或 order 表 - 输出 workers[] 或 order 表
- 追问空洞「你想要什么体验?」无选项 - 追问空洞「你想要什么体验?」无选项
@@ -5,7 +5,7 @@
# declaration = catalog 短声明(可被 prompt.md ```meta 覆盖) # declaration = catalog 短声明(可被 prompt.md ```meta 覆盖)
# artifact = 执行期产物 tag(程序映射;不写进流程 JSON) # artifact = 执行期产物 tag(程序映射;不写进流程 JSON)
# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。 # repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。
# 回头修改已完成节点不是 repeatable:DAG 用 mode=revise + revises,继承旧产物。 # 回头修改已完成节点不是 repeatable:用户在图上点该节点重进,不要排 mode=revise。
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿 # closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收) # auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收)
# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行) # kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行)
@@ -21,7 +21,7 @@
# #
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。 # 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。 # 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。
# 已完成节点要改:编排 mode=revise(继承旧稿),不要再排一条 fresh。 # 已完成节点要改:用户在图上点该节点重进(继承旧稿),不要再排一条 fresh 或 mode=revise。
# 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。 # 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。
# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。 # 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。
@@ -77,7 +77,7 @@ modules:
hint: 本批要锚定什么(如开局重要NPC) hint: 本批要锚定什么(如开局重要NPC)
- key: count - key: count
label: 数量 label: 数量
hint: 数字或「按规则」;contextual 时写清规模依据 hint: 同批时本批条数;规则「一次生成」为单条时忽略(本步只交 1 条)
- id: narrative - id: narrative
name: 叙事指南与故事推进 name: 叙事指南与故事推进
@@ -28,10 +28,10 @@ feeds: gm
```opening ```opening
先认定本批按哪条规则生成: 先认定本批按哪条规则生成:
- 若【规划产物】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。 - 若【本步对象】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
- 若规划为空,从已验收生成规则里选出一条预生成规则。 - 若规划为空,从已验收生成规则里选出一条预生成规则。
有批次目标或数量偏好可一句带过;否则直接「按规则生成」。 读规则「一次生成」:同批则本步一次交齐互有关系的多条;单条则本步只交 1 条。有批次目标可一句带过。
``` ```
## task ## task
@@ -41,12 +41,15 @@ feeds: gm
本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。 本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【规划产物】当起点;规划可空。 本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【本步对象】与【原型建议】;在本步钉「写什么」。
执行顺序: 执行顺序:
1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。 1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。
2. 数量:规划 `count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。 2. 条数:先读规则「一次生成」。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(单层 JSON;键=schema 顶层字段)。 - `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。 4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。
5. 输出 JSON;追问通常 `[]`。 5. 输出 JSON;追问通常 `[]`。
@@ -56,15 +59,16 @@ summary:`具体实例 · {对象} · {n}条`
## principles ## principles
```principles ```principles
1. 只按规则来:方法、格式、池、数量、约束——规则有什么用什么;没有的不发明。 1. 只按规则来:方法、格式、池、数量、一次生成、约束——规则有什么用什么;没有的不发明。
2. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。 2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。
3. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。 3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
4. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
``` ```
## probe ## probe
```probe ```probe
通常不追问。仅当 contextual 数量算不出时问一句规模依据。 通常不追问。仅当 contextual 数量算不出、或同批「关系」写了但本批对象对不齐时,问一句。
用户要 schema 外内容 → 提示回「生成规则」。 用户要 schema 外内容 → 提示回「生成规则」。
``` ```
@@ -110,7 +114,7 @@ summary:`具体实例 · {对象} · {n}条`
硬规则: 硬规则:
1. 合法 JSON;含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`。 1. 合法 JSON;含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`。
2. 正文键固定:`rule_id` / `本批数量` / `批次条件` / `batch_id` / `records` / `增量说明`。 2. 正文键固定:`rule_id` / `本批数量` / `批次条件` / `batch_id` / `records` / `增量说明`。
3. `records` 每条必须是规则约定的单层 JSON;`本批数量 === records.length`。 3. `records` 始终为数组;每条必须是规则约定的单层 JSON;`本批数量 === records.length`。规则「一次生成」为单条时长度为 1;为同批时 ≥2 且关系对齐。
4. 自评仅「合规模」一维。禁止改规则、写 `order`、输出执行单元列表。 4. 自评仅「合规模」一维。禁止改规则、写 `order`、输出执行单元列表。
5. summary:`具体实例 · {对象} · {n}条`。 5. summary:`具体实例 · {对象} · {n}条`。
@@ -118,6 +122,7 @@ summary:`具体实例 · {对象} · {n}条`
```checklist ```checklist
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行? - [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组?
- [ ] records 单层、键值符合产物格式与全部约束? - [ ] records 单层、键值符合产物格式与全部约束?
- [ ] 自评为合规模?未改合同? - [ ] 自评为合规模?未改合同?
``` ```
@@ -125,6 +130,8 @@ summary:`具体实例 · {对象} · {n}条`
## examples ## examples
```examples ```examples
好:规则要 3 条怪物 → 交出 3 条单层 record,字段与池用法全服从规则。 好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。 坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
``` ```
@@ -37,13 +37,14 @@ feeds: gm,auditor
```opening ```opening
先认定本轮规则写哪一类内容: 先认定本轮规则写哪一类内容:
- 若【规划产物】已有对象,以此为起点;要改对象就在本步改。 - 若【本步对象】/【原型建议】已有对象提示,以此为起点;要改对象就在本步改。
- 若规划为空,先钉对象族,再谈规则。 - 若规划为空,先钉对象族,再谈规则。
然后谈: 然后谈:
1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果? 1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果?
2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成? 2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
3. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有) 3. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。
4. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
想到什么写什么;完整规则在正式产物里钉。 想到什么写什么;完整规则在正式产物里钉。
``` ```
@@ -59,7 +60,7 @@ feeds: gm,auditor
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举; 3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举;
一条规则可多个池;无则 `池: []`。 一条规则可多个池;无则 `池: []`。
本步是〔先验产物〕:先定本轮对象族,再写规则。读【规划产物】当起点;规划可空。禁止顺手扩到其它族。 本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
执行顺序: 执行顺序:
1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。 1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。
@@ -68,14 +69,17 @@ feeds: gm,auditor
- `runtime_only`:仅游玩期动态生成;不要求「具体实例」。 - `runtime_only`:仅游玩期动态生成;不要求「具体实例」。
- `seed_only`:由「具体实例」预生成;规则用完可丢。 - `seed_only`:由「具体实例」预生成;规则用完可丢。
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。 - `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。 4. 钉「数量」与「一次生成」(两件不同的事):
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。 - **数量**:这类对象总共要有几条(fixed / range / contextual)。
6. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。 - **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`(一次交出并对齐关系);彼此独立 → `单条`(一次只交 1 条,要更多就再点原型)。产物形状始终是 `records[]`,不因单条改成单对象。
5. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
6. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
7. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。 若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。 8. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
若程序已发默认问题:禁止重复开场;在首答上补洞。 若程序已发默认问题:禁止重复开场;在首答上补洞。
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态}`;跳过 → `生成规则 · {对象} · 无需专门规则`。 summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
``` ```
## principles ## principles
@@ -90,7 +94,8 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。 类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。
6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。 6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。
7. 生命周期先行;未定前不假装可执行「具体实例」。 7. 生命周期先行;未定前不假装可执行「具体实例」。
8. 不写变量更新公式、不写剧情推进、不填最终实例。 8. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系(亲属、对立、同场搭档等)→ `同批`;彼此独立 → `单条`。`同批` 至少 2 条,否则改 `单条`。不要把「总共要几个」(数量)和「这一次交几条」(一次生成)混成一件事。
9. 不写变量更新公式、不写剧情推进、不填最终实例。
``` ```
## probe ## probe
@@ -98,7 +103,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
```probe ```probe
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。 每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
优先:整条规则是否必要;某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。 优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(一次生成:同批 vs 单条);某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
若双门槛不成立 → 建议跳过,停追问字段与格式。 若双门槛不成立 → 建议跳过,停追问字段与格式。
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。 不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。
``` ```
@@ -141,6 +146,10 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
"最大": 1, "最大": 1,
"决定规则": "contextual 时必填,可较长" "决定规则": "contextual 时必填,可较长"
}, },
"一次生成": {
"模式": "单条|同批",
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」"
},
"生成与描写": { "生成与描写": {
"依据": ["可较长;引用上游产物或用户要求"], "依据": ["可较长;引用上游产物或用户要求"],
"方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"], "方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"],
@@ -236,7 +245,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。 1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。 2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。 3. **正文键固定**:本步参数、必要性判断、rules、增量说明。
4. **每条 rule 必含**:生成与描写、产物格式;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。 4. **每条 rule 必含**:生成与描写、产物格式、数量、一次生成;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。 5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。
6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。 6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。 7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
@@ -248,7 +257,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
- [ ] 含 schema / brief / 正文 / 自评 / 追问? - [ ] 含 schema / brief / 正文 / 自评 / 追问?
- [ ] 自评为必要性 / 属性妥当 / 格式准确? - [ ] 自评为必要性 / 属性妥当 / 格式准确?
- [ ] 整条规则必要性有举证?不成立时 rules=[]? - [ ] 整条规则必要性有举证?不成立时 rules=[]?
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验? - [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?数量与一次生成已分清(总共几个 vs 这一次交几条)?同批有关系、单条写彼此独立?
- [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表? - [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表?
- [ ] 未夹带最终实例、变量公式、其它对象族、百科? - [ ] 未夹带最终实例、变量公式、其它对象族、百科?
``` ```
@@ -259,11 +268,15 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
好: 好:
- D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。 - D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。
- 女主规则:不设「性别」字段;关系切面进表;池可空。 - 女主规则:不设「性别」字段;关系切面进表;池可空。
- 互为仇敌的两名开局 NPC:一次生成=同批,关系写清对立如何对齐;数量=2。
- 可互换的酒馆路人:一次生成=单条,关系写「彼此独立」;要 5 个就点 5 次。
- 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。 - 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。
- 普通古董 → 无需专门规则(必要性维度说明跳过理由)。 - 普通古董 → 无需专门规则(必要性维度说明跳过理由)。
坏: 坏:
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。 - 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。 - 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
- 有亲属关系的三人却标成单条,分三次各写一条,关系对不上。
- 把「总共要 5 个」写成一次生成=同批,但彼此并无必须同场的关系。
- 把写好的 NPC 整份塞进池当最终实例。 - 把写好的 NPC 整份塞进池当最终实例。
``` ```
@@ -46,7 +46,7 @@ uiPrompt: |
你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点: 你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点:
- 没有 DAG 或 DAG 需要追加节点:`design-flow` - 没有 DAG、需要追加节点,或用户要求补前序节点(即使下一步已是收口):`design-flow`
- DAG 已给出下一个可执行节点:`design-step` - DAG 已给出下一个可执行节点、且用户没有要求改排:`design-step`
节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。 节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。
@@ -3,8 +3,8 @@ id: design-flow
skill: world-simulator skill: world-simulator
name: 创作 · 流程编排 name: 创作 · 流程编排
description: >- description: >-
【编排】以用户已选配方为起点,从能力池排出近期工序与依赖, 【编排】以用户已选配方为起点,从能力池排出近期 DAG 骨架(节点 + 依赖 + 原型槽位 + 建议)。
产出/修订可变增量 DAG(设计.创作流程)。本步不写美学/机制正文,不写 Worker 集。 不决定执行顺序;用户在图上点选。本步不写美学/机制正文。
version: 1 version: 1
stage: design stage: design
inputTags: inputTags:
@@ -40,70 +40,101 @@ contextSegments:
# 创作 · 流程编排 # 创作 · 流程编排
你只做一件事:根据【用户要求】编排或**增量修订**一份剧本流程(可变 DAG)。要求按先后都有效,冲突时以较新的为准。 你只做一件事:根据【用户要求】编排或**增量修订**一份 **DAG 骨架**。要求按先后都有效,冲突时以较新的为准。
**你不决定**用户下一步先跑哪个——用户在分层图上点选。你只产出:有哪些节点、depends_on、原型槽位(repeatable 能力)、以及建议。
上下文里会有: 上下文里会有:
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。 1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、原型槽位。必须先读完再排。
2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方 2. **【用户已选配方】**:方法论 + 近期起点——**不是**锁死流水线
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次**新建**编入;已完成节点要改用回头修改(mode=revise) 3. **【能力 · 可选工序】**:步骤只能从这里选
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来 4. **【已有剧本草案】/【已验收步骤】**:在其上追加,**不要**推倒重来
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。
## 增量 DAG(核心) ## 增量 DAG(核心)
**禁止**一次排完全程固定长链。每次只排出**近期要做**的步骤(通常 1~4 步),`status` 默认 `"open"`。 **禁止**一次排完全程固定长链。每次只排出**近期 horizon**(通常 1~4 个节点 + 若干原型),`status` 默认 `"open"`。
选型规则: ### 节点类型
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。 | 类型 | JSON | 谁跑 |
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。 |------|------|------|
- 〔先验产物〕(生成规则、具体实例):可提前规划「写什么」写入 params;执行时程序注入【规划产物】。规划可空——对象在步内钉。提前规划多条(不同对象/批次)是正确的。**禁止**为钉「生成什么 / 调用哪个规则」而 askUser 拦在确认开干前。 | 普通一步 | 缺省(无 role) | 用户点上直接 design-step |
- 其它有「编排参数」的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params。 | **原型槽位** | `"role": "prototype"` | 用户点上 → 程序增殖 instance → design-step |
- 用户先认可**下一节点**,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。 | **实例** | `"role": "instance"`, `"from": "<原型 id>"` | 仅程序增殖,**禁止**你在编排层预排 |
同能力**可以**多次出现。必须分清两种: **禁止**排 `mode=revise` / 回头修改节点。已完成节点要改:用户在图上点该节点重进,不在 DAG 里加修订步。
- **彻底新建**(〔可反复〕如生成规则、具体实例):`mode` 缺省或 `"fresh"`,新 id + 新 params。每一次都是新对象/新批次,**不要**改旧条。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。 ### 〔可反复〕= 原型,不是多条 instance
- **回头修改**:用户要改**已经做过**的节点(漏了、谈浅了、后来发现要改)→ 追加新 id,`"mode": "revise"`,`"revises": "<原步 id>"`。程序会注入既有产物,在旧稿上改。非反复技能**禁止**再排一条 fresh;只能 revise。插在最近已验收步之后、尚未执行的步之前。
生成规则、具体实例等 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 ```json
{ {
"brief": "一句话复述用户要的体验(可选)", "brief": "一句话体验(可选)",
"status": "open", "status": "open",
"suggestions": [
{
"name": "具体实例",
"suggestion": "开局 2~3 个重要 NPC",
"depends_on": ["生成规则"]
}
],
"steps": [ "steps": [
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }, { "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
{ {
"id": "生成规则·怪物", "id": "生成规则",
"name": "生成规则", "name": "生成规则",
"params": { "role": "prototype",
"target": "怪物", "suggestion": "先写怪物种群生成规则",
"rule_id": "monsters",
"lifecycle_intent": "runtime_only"
},
"depends_on": ["美学纲领与交互范式"] "depends_on": ["美学纲领与交互范式"]
}, },
{ {
"id": "具体实例·重要NPC·开局", "id": "具体实例",
"name": "具体实例", "name": "具体实例",
"params": { "role": "prototype",
"rule_id": "major-npcs", "suggestion": "锚定 1~2 个开局 NPC",
"batch_goal": "开局重要NPC", "depends_on": ["生成规则"]
"count": "按规则"
},
"depends_on": ["生成规则·重要NPC"]
}, },
{ {
"id": "美学纲领与交互范式·改", "id": "开场白与开场变量",
"name": "美学纲领与交互范式", "name": "开场白与开场变量",
"mode": "revise", "depends_on": ["美学纲领与交互范式", "具体实例"]
"revises": "美学纲领与交互范式",
"depends_on": ["实现机制"]
} }
] ]
} }
@@ -111,44 +142,20 @@ contextSegments:
规则: 规则:
1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤) 1. `steps` 顺序 = 建议阅读顺序;`depends_on` 须指向更前步骤 id
2. `name` = 能力池里的固定中文名(可重复) 2. `name` = 能力池固定中文名
3. `id` = 本局步骤唯一键;同 name 多次时必须不同;新建优先语义化(`生成规则·{对象}`);回头修改用 `{原id}·改` 3. `id` = 本局唯一;原型 id 稳定(如 `生成规则`);实例 id 由程序增殖(`生成规则#1`…)
4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name) 4. **禁止**在编排层为 repeatable 预排 instance 或写 params
5. `params` = 本步调用参数(对象)。〔先验产物〕:此处是规划产物(可空;已知对象就填,便于区分多条)。其它能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写。回头修改可省略,程序会从 `revises` 那步继承 5. 已验收 id **必须保留**
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成) 6. 非反复已完成 → 禁止再排新建,**禁止**排 `mode=revise`
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params 7. 收口「开场白与开场变量」排最后;选定前用户补节点 → 插收口前、`status: "open"`
8. 已完成的**非反复**技能禁止再排一条 `mode=fresh`;要改旧稿只能追加 `mode=revise` + `revises`。〔可反复〕再编入新 id 且 fresh = 彻底新建(新对象/新批次),不是改旧条;改旧条同样用 revise 8. askUser 只问排布:补哪个原型、改依赖。**禁止**问体验正文,**禁止**问要不要回头修改
9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满 9. `summary`:`流程 · N 步 · open|closed · …`
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。
这是**改这条规则的对象**(回头修改或改未验收步),与「再来一条新的生成规则」不是一回事。
## 自检 ## 自检
- 用户是否已选配方?(未选则不要硬编) - 只排了骨架,没有替用户决定顺序?
- 是否只排了近期 horizon,而不是假固定全图? - repeatable 是否都是 `role=prototype` + suggestion,没有 instance/params?
- 是否误排了多次「发明 Worker」?应改为至多一次「游玩拓扑」 - suggestions 是否简短、可执行?
- 每步是否按能力 meta 的何时用/不用判断过,而非题材默认? - depends_on 是否合法(具体实例原型依赖生成规则原型 = 至少一条规则实例验收后才可增殖)?
- 每步 name 都在【能力】里?同名多次是否都有不同 id? - 已验收 id 是否都在?
- 有编排参数的普通步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
- 〔先验产物〕是否误把「生成什么」当成确认开干前必填?规划可空;已知对象才写入 params
- depends_on 是否都指向更靠前的步骤 id?
- 已验收 id 是否都还在?
- 【流程进度】里已完成的非反复技能,是否误当成 fresh 又排了一次?要改旧稿应 mode=revise
- askUser 是否只问了排哪步 / 填哪个参数 / 新建还是回头修改,而不是体验偏好等正文问题?
- 需要反复补同类内容时,是否用了新 id + 新 params + fresh 追加(彻底新建),而不是改写旧步?
- 用户要改已完成节点时,是否用了 mode=revise + revises,而不是再排一条 fresh?
- 收成前是否把 `status` 设为 `closed`?
@@ -42,14 +42,14 @@ contextSegments:
同能力可多次出现(不同 id): 同能力可多次出现(不同 id):
- **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。 - **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。
- **回头修改**(mode=revise):以【既有产物 · 继承修改】为底稿,改用户点名的部分,保留其余;不从零另起,不再走开场询问。 - **回头修改**(程序注入【既有产物 · 继承修改】):用户从图上点已完成节点重进。以底稿继续改用户点名的部分,保留其余;不从零另起,不再走开场询问。
- **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。 - **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。
## 执行 ## 执行
1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。 1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。
2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。 2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。
3. 按方法正文填产物。〔先验产物〕读【规划产物】当起点(可空、可在步内修订);其它节点编排参数见【本步参数】,直接采用。 3. 按方法正文填产物。〔先验产物〕读【本步对象】在本步与用户钉「写什么」;可参考【原型建议】。其它节点编排参数见【本步参数】,直接采用。
4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …` 4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …`
## 缺料时 ## 缺料时
@@ -62,4 +62,4 @@ contextSegments:
- 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。 - 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。
- 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。 - 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。
需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。 需要追加「再来一次」时,由编排器再调流程编排。已完成节点要改:用户自己在图上点该节点,不要让编排器加修订步。
+79 -28
View File
@@ -1,6 +1,12 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path"; 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 type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js"; import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js"; import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
@@ -11,8 +17,15 @@ export type ApiProfile = {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model: string; model: string;
/**
* 该配置默认思考强度(写入请求的 reasoning_effort)。
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。
*/
reasoningEffort?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** 能力探测结果;改 baseUrl/model 后应重测 */
capabilities?: ProfileCapabilities;
}; };
type ApiProfilesFile = { type ApiProfilesFile = {
@@ -45,6 +58,16 @@ function writeFile(data: ApiProfilesFile): void {
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8"); 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 { function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data; if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional(); const env = loadLlmConfigOptional();
@@ -79,15 +102,18 @@ export function createApiProfile(input: {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model: string; model: string;
reasoningEffort?: string;
}): ApiProfile { }): ApiProfile {
const data = readFile(); const data = readFile();
const now = new Date().toISOString(); const now = new Date().toISOString();
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
const profile: ApiProfile = { const profile: ApiProfile = {
id: randomUUID(), id: randomUUID(),
name: input.name.trim() || "未命名", name: input.name.trim() || "未命名",
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com", baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
apiKey: input.apiKey.trim(), apiKey: input.apiKey.trim(),
model: input.model.trim() || "deepseek-v4-pro", model: input.model.trim() || "deepseek-v4-pro",
...(reasoningEffort ? { reasoningEffort } : {}),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -98,20 +124,52 @@ export function createApiProfile(input: {
export function updateApiProfile( export function updateApiProfile(
id: string, id: string,
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>, input: Partial<
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
>,
): ApiProfile { ): ApiProfile {
const data = readFile(); const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id); const index = data.profiles.findIndex((p) => p.id === id);
if (index < 0) throw new Error("API 配置不存在"); if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]; 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 = { const updated: ApiProfile = {
...current, ...current,
name: input.name?.trim() || current.name, name: input.name?.trim() || current.name,
baseUrl: input.baseUrl?.trim() || current.baseUrl, baseUrl: nextBase,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== "" apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim() ? input.apiKey.trim()
: current.apiKey, : 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(), updatedAt: new Date().toISOString(),
}; };
data.profiles[index] = updated; data.profiles[index] = updated;
@@ -119,6 +177,20 @@ export function updateApiProfile(
return updated; 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 { export function deleteApiProfile(id: string): void {
const data = readFile(); const data = readFile();
data.profiles = data.profiles.filter((p) => p.id !== id); data.profiles = data.profiles.filter((p) => p.id !== id);
@@ -130,30 +202,9 @@ export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
baseUrl: profile.baseUrl, baseUrl: profile.baseUrl,
apiKey: profile.apiKey, apiKey: profile.apiKey,
model: profile.model, 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 { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { ProfileCapabilities } from "../llm/capabilities.js";
export type LlmConfig = { export type LlmConfig = {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model: string; model: string;
/**
* API 配置上的默认思考强度;generation 未指定时写入 reasoning_effort。
* 空 / auto = 不发送,走供应商默认。
*/
reasoningEffort?: string;
/** 来自 API profile 探测结果;决定 structured 投递选路 */
capabilities?: ProfileCapabilities;
}; };
export function loadLlmConfig(): LlmConfig { 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;
}
+47 -28
View File
@@ -1,11 +1,18 @@
import type { LlmConfig } from "../config/env.js"; import type { LlmConfig } from "../config/env.js";
import type { GenerationParameters } from "../types/preset.js"; import type { GenerationParameters } from "../types/preset.js";
import {
applyOpenAiGeneration,
fetchWithGenerationCompat,
sanitizeReasoningEffort,
} from "./generation-compat.js";
import { consumeOpenAiToolStream } from "./stream-complete.js"; import { consumeOpenAiToolStream } from "./stream-complete.js";
import { import {
currentAbortSignal, currentAbortSignal,
throwIfAborted, throwIfAborted,
} from "./run-abort.js"; } from "./run-abort.js";
export { sanitizeReasoningEffort };
export type ToolCallPayload = { export type ToolCallPayload = {
id: string; id: string;
type: "function"; type: "function";
@@ -30,6 +37,8 @@ export type ToolDefinition = {
name: string; name: string;
description: string; description: string;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
/** 部分供应商支持的严格参数约束 */
strict?: boolean;
}; };
}; };
@@ -57,8 +66,16 @@ export type CompleteResult = {
model?: string; model?: string;
}; };
export type JsonSchemaFormat = {
name: string;
strict?: boolean;
schema: Record<string, unknown>;
};
export type CompleteOptions = { export type CompleteOptions = {
responseFormat?: "json_object" | "text"; responseFormat?: "json_object" | "json_schema" | "text";
/** responseFormat=json_schema 时使用 */
jsonSchema?: JsonSchemaFormat;
generation?: GenerationParameters; generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */ /** 统计用途,如 main_agent / worker:write-rules */
caller?: string; caller?: string;
@@ -66,8 +83,15 @@ export type CompleteOptions = {
signal?: AbortSignal; signal?: AbortSignal;
}; };
export type ToolChoice =
| "auto"
| "required"
| "none"
| { type: "function"; function: { name: string } };
export type CompleteWithToolsOptions = CompleteOptions & { export type CompleteWithToolsOptions = CompleteOptions & {
tools: ToolDefinition[]; tools: ToolDefinition[];
toolChoice?: ToolChoice;
}; };
export type CompleteWithToolsResult = { export type CompleteWithToolsResult = {
@@ -104,12 +128,11 @@ export type LlmProvider = {
): Promise<CompleteWithToolsResult>; ): Promise<CompleteWithToolsResult>;
}; };
function buildRequestBody( export function buildRequestBody(
config: LlmConfig, config: LlmConfig,
messages: ChatMessage[], messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean }, options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> { ): Record<string, unknown> {
const gen = options?.generation ?? {};
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: config.model, model: config.model,
messages, messages,
@@ -117,34 +140,28 @@ function buildRequestBody(
if (options?.tools?.length) { if (options?.tools?.length) {
body.tools = options.tools; body.tools = options.tools;
body.tool_choice = "auto"; body.tool_choice =
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
} }
if (gen.temperature !== undefined) body.temperature = gen.temperature; applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
else body.temperature = 0.2; reasoningEffort: config.reasoningEffort,
});
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;
}
if (options?.responseFormat === "json_object") { if (options?.responseFormat === "json_object") {
body.response_format = { type: "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) { if (options?.stream) {
@@ -246,9 +263,10 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
async function llmFetch( async function llmFetch(
url: string, url: string,
apiKey: string, apiKey: string,
body: unknown, body: Record<string, unknown>,
options?: CompleteOptions, options?: CompleteOptions,
): Promise<Response> { ): Promise<Response> {
return fetchWithGenerationCompat(async (payload) => {
throwIfAborted(options?.signal); throwIfAborted(options?.signal);
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
@@ -256,9 +274,10 @@ async function llmFetch(
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(payload),
signal: options?.signal ?? currentAbortSignal(), signal: options?.signal ?? currentAbortSignal(),
}); });
}, body);
} }
export class OpenAiCompatibleProvider implements LlmProvider { 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: "执行单元提问", worker_questions: "执行单元提问",
revision: "修订", revision: "修订",
next_intent: "下一步意向", next_intent: "下一步意向",
pick_creation_step: "点选节点",
}; };
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = { const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
@@ -56,9 +57,14 @@ const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
main_agent_decision_created: "总管决策", main_agent_decision_created: "总管决策",
user_approved_next_step: "确认下一步", user_approved_next_step: "确认下一步",
user_rejected_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_started: "执行单元开始",
worker_completed: "执行单元完成", worker_completed: "执行单元完成",
worker_needs_input: "执行单元提问", worker_needs_input: "执行单元提问",
worker_revision_produced_nothing: "修订无新产物",
user_resolved_sidecar_questions: "回答挂载追问", user_resolved_sidecar_questions: "回答挂载追问",
user_accepted_artifact: "验收通过", user_accepted_artifact: "验收通过",
user_rejected_artifact: "打回产物", user_rejected_artifact: "打回产物",
@@ -78,6 +84,7 @@ const EFFECT_LABEL: Record<string, string> = {
emit_message: "发消息", emit_message: "发消息",
propose_next_creation_step: "提案下一步", propose_next_creation_step: "提案下一步",
seal_creation_opening: "收口开场白", seal_creation_opening: "收口开场白",
unseal_creation_opening: "解开开场收口",
run_play_turn: "开游玩回合", run_play_turn: "开游玩回合",
continue_play_turn: "续游玩管线", continue_play_turn: "续游玩管线",
}; };
+16 -15
View File
@@ -24,25 +24,26 @@ function buildMainAgentSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)"; : "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。 return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
- DAG 不存在或需要追加节点:调用 design-flow。
- DAG 中已有下一个可执行节点:调用 design-step。
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
当前 skill 可用 worker(workerId 必须与下列 id 完全一致): 当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
${workerLines} ${workerLines}
输出必须是 JSON 对象,字段: 结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
{ }
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
"reason": "string", /** 总管把推理写成正文、未给出可用工具/JSON 时,运行时改走点选或 design-flow。 */
"assessment": "string | null", export function isUnusableMainAgentOutput(err: unknown): boolean {
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null, const msg = err instanceof Error ? err.message : String(err);
"workerId": "string | null", return (
"requiresApproval": boolean, /invalid JSON/i.test(msg) ||
"workerContext": { "roleId": "string" } | null /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 { export class MainAgent {
+2 -7
View File
@@ -42,17 +42,12 @@ function buildToolLoopSystemPrompt(
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n") ? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
: "- (当前 skill 未加载 worker 列表)"; : "- (当前 skill 未加载 worker 列表)";
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。 return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
当前 skill 可用 worker: 当前 skill 可用 worker:
${workerLines} ${workerLines}
选定节点后立即调用 run_worker。`; 结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
} }
function executeLoopTool( 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 "invoke_main_agent":
case "propose_next_creation_step": case "propose_next_creation_step":
case "seal_creation_opening": case "seal_creation_opening":
case "unseal_creation_opening":
case "run_play_turn":
case "continue_play_turn":
break; break;
} }
} }
+167 -10
View File
@@ -35,6 +35,11 @@ import {
SLOT_CREATION_SEALED_BY_OPENING, SLOT_CREATION_SEALED_BY_OPENING,
isOpeningSealArtifact, isOpeningSealArtifact,
} from "../skills/opening-seal.js"; } 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 { inferLifecycleStage } from "../skills/worker-declaration.js";
import type { QuestionItem } from "../types/questions.js"; import type { QuestionItem } from "../types/questions.js";
@@ -146,6 +151,54 @@ function running(session: RuntimeSession): RuntimeSession {
return touch({ ...session, phase: "running", waitingReason: undefined }); 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( function afterPlayOrMainAgent(
session: RuntimeSession, session: RuntimeSession,
@@ -286,6 +339,7 @@ export function getAllowedEvents(
case "running": case "running":
return [ return [
"main_agent_decision_created", "main_agent_decision_created",
"creation_step_pick_awaited",
"worker_started", "worker_started",
"worker_completed", "worker_completed",
"worker_needs_input", "worker_needs_input",
@@ -303,6 +357,9 @@ export function getAllowedEvents(
"user_confirmed_intake", "user_confirmed_intake",
"user_approved_next_step", "user_approved_next_step",
"user_rejected_next_step", "user_rejected_next_step",
"user_requested_flow_replan",
"user_picked_creation_step",
"user_left_creation_step",
"user_accepted_artifact", "user_accepted_artifact",
"user_rejected_artifact", "user_rejected_artifact",
"user_requested_revision", "user_requested_revision",
@@ -341,13 +398,34 @@ export function canApplyEvent(
reason?.kind === "input" || reason?.kind === "input" ||
reason?.kind === "worker_questions" || reason?.kind === "worker_questions" ||
reason?.kind === "revision" || reason?.kind === "revision" ||
reason?.kind === "next_intent" reason?.kind === "next_intent" ||
reason?.kind === "pick_creation_step"
); );
case "user_confirmed_intake": case "user_confirmed_intake":
return reason?.kind === "intake"; return reason?.kind === "intake";
case "user_approved_next_step": case "user_approved_next_step":
case "user_rejected_next_step": case "user_rejected_next_step":
return reason?.kind === "approve_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_accepted_artifact":
case "user_rejected_artifact": case "user_rejected_artifact":
return reason?.kind === "review_artifact"; return reason?.kind === "review_artifact";
@@ -361,8 +439,12 @@ export function canApplyEvent(
reason?.kind === "review_artifact" || reason?.kind === "approve_step" reason?.kind === "review_artifact" || reason?.kind === "approve_step"
); );
case "main_agent_decision_created": case "main_agent_decision_created":
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话 // running 时可决策;waiting_user(input/pick) 时允许总管在启动阶段插话或点选后补参确认
return session.phase === "running" || reason?.kind === "input"; return (
session.phase === "running" ||
reason?.kind === "input" ||
reason?.kind === "pick_creation_step"
);
default: default:
return true; return true;
} }
@@ -420,13 +502,20 @@ function handleWorkerCompleted(
if (mode === "no_confirmation") { if (mode === "no_confirmation") {
next = updateArtifact(next, artifact.id, { status: "accepted" }); next = updateArtifact(next, artifact.id, { status: "accepted" });
return afterPlayOrMainAgent(session, { const cleared = {
...next, ...next,
pendingArtifactId: undefined, pendingArtifactId: undefined,
currentWorkerId: undefined, currentWorkerId: undefined,
pendingDecision: undefined, pendingDecision: undefined,
resumeContext: 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 // 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") { if (session.waitingReason?.kind === "revision") {
const instruction = const instruction =
text.trim() || session.waitingReason.instruction?.trim() || ""; text.trim() || session.waitingReason.instruction?.trim() || "";
@@ -836,16 +929,81 @@ export function applyEvent(
} }
case "user_rejected_next_step": { 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 { return {
session: touch( session: touch(
running( running(
appendHistory( appendHistory(
{ ...session, pendingDecision: undefined }, {
...session,
currentWorkerId: DESIGN_STEP_WORKER_ID,
pendingDecision: undefined,
},
event, 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") { if (inferLifecycleStage(session) === "play") {
return afterPlayOrMainAgent(session, cleared); return afterPlayOrMainAgent(session, cleared);
} }
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。 // 创作步验收后停在分层图上等点选(可反复能力可当场新开一条)。
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。 // 仅当没有任何可点节点时,propose 才会把控制权交回总管扩步。
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
const proposeNext = const proposeNext =
artifact.workerId === "design-step" || artifact.workerId === "design-step" ||
artifact.workerId === "design-flow"; artifact.workerId === "design-flow";
+626 -127
View File
@@ -17,7 +17,11 @@ import {
} from "./phase-machine.js"; } from "./phase-machine.js";
import { Blackboard } from "../blackboard/blackboard.js"; import { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider } from "../llm/client.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 { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js";
import { import {
buildInstanceWorkerDeclaration, buildInstanceWorkerDeclaration,
@@ -58,14 +62,32 @@ import {
CREATION_FLOW_TAG, CREATION_FLOW_TAG,
CREATION_CURRENT_STEP_TAG, CREATION_CURRENT_STEP_TAG,
CREATION_PROPOSED_STEP_TAG, CREATION_PROPOSED_STEP_TAG,
CREATION_INHERIT_EXISTING_TAG,
CREATION_MODULE_OPENING_TAG, CREATION_MODULE_OPENING_TAG,
CREATION_MODULE_OPENING_STATE_TAG, CREATION_MODULE_OPENING_STATE_TAG,
SLOT_CREATION_MODULE_OPENING_STATE, SLOT_CREATION_MODULE_OPENING_STATE,
loadModuleCatalog, loadModuleCatalog,
isReviseStep, isReviseStep,
isStepAccepted,
isInheritExistingFlag,
mergeCreationFlowPreservingAccepted, mergeCreationFlowPreservingAccepted,
nextPendingStep, nextPendingStep,
listReadySteps,
listSelectablePrototypes,
listRepeatableSpawns,
isStepReady,
hasSelectableCreationWork,
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
removeUnstartedInstance,
isPrototypeStep,
isPrototypeSelectable,
isInstanceStep,
findStepByRef,
findModuleByName,
parseCreationFlow, parseCreationFlow,
DESIGN_FLOW_WORKER_ID,
DESIGN_STEP_WORKER_ID,
parseModuleOpeningState, parseModuleOpeningState,
patchCreationFlowStepParams, patchCreationFlowStepParams,
pickRecordedStepId, pickRecordedStepId,
@@ -76,7 +98,10 @@ import {
stepUnitId, stepUnitId,
stringifyCreationFlow, stringifyCreationFlow,
stringifyModuleOpeningState, stringifyModuleOpeningState,
finalizeCreationFlow,
type CreationFlow,
type CreationFlowStepParams, type CreationFlowStepParams,
type ModuleCatalog,
type ModuleCatalogEntry, type ModuleCatalogEntry,
type ModuleParamSpec, type ModuleParamSpec,
} from "../skills/creation-flow.js"; } from "../skills/creation-flow.js";
@@ -98,6 +123,7 @@ import {
OPENING_OUTPUT_TAG, OPENING_OUTPUT_TAG,
OPENING_SETUP_ARTIFACT_TAG, OPENING_SETUP_ARTIFACT_TAG,
parseOpeningSealPayload, parseOpeningSealPayload,
reopenCreationFlowRaw,
SLOT_CREATION_SEALED_BY_OPENING, SLOT_CREATION_SEALED_BY_OPENING,
SLOT_OPENING_SELECTED_INDEX, SLOT_OPENING_SELECTED_INDEX,
} from "../skills/opening-seal.js"; } from "../skills/opening-seal.js";
@@ -326,6 +352,56 @@ export class PhaseRuntime {
throw new Error("当前没有可重试的执行"); 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 回复) */ /** 重 roll 指定 worker(刷新 Skill 回复) */
async rerunWorker(workerId: string): Promise<RuntimeSession> { async rerunWorker(workerId: string): Promise<RuntimeSession> {
const effect: Extract<PhaseEffect, { type: "run_worker" }> = { const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
@@ -595,7 +671,7 @@ export class PhaseRuntime {
return this.session; return this.session;
} }
/** 用户拒绝 pendingDecision,回到总管 */ /** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */
async rejectStep(reason?: string): Promise<RuntimeSession> { async rejectStep(reason?: string): Promise<RuntimeSession> {
const id = this.session.pendingDecision?.id; const id = this.session.pendingDecision?.id;
if (!id) throw new Error("当前没有待拒绝的决策"); if (!id) throw new Error("当前没有待拒绝的决策");
@@ -606,6 +682,180 @@ export class PhaseRuntime {
return this.session; 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 持久化与调试 */ /** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */
private persistInstanceWorkerDeclaration(): void { private persistInstanceWorkerDeclaration(): void {
const active = this.getActiveSkill(); const active = this.getActiveSkill();
@@ -857,6 +1107,13 @@ export class PhaseRuntime {
} }
break; break;
case "run_worker": case "run_worker":
if (
effect.workerId === DESIGN_STEP_WORKER_ID &&
!this.hasPinnedReadyStep()
) {
await this.awaitCreationStepPick();
break;
}
if (this.autoStubWorker) { if (this.autoStubWorker) {
await this.runStubWorker(effect); await this.runStubWorker(effect);
} else if (this.llm) { } else if (this.llm) {
@@ -894,6 +1151,9 @@ export class PhaseRuntime {
case "seal_creation_opening": case "seal_creation_opening":
this.sealCreationOpening(); this.sealCreationOpening();
break; break;
case "unseal_creation_opening":
this.unsealCreationOpening();
break;
case "run_play_turn": case "run_play_turn":
await this.startPlayTurn(); await this.startPlayTurn();
break; break;
@@ -905,9 +1165,243 @@ export class PhaseRuntime {
} }
/** /**
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。 * 用户填完「下一步想写什么」后:有可点节点则停在分层图;程序步自动开干;否则交总管扩步。
*/ */
private async proposeNextCreationStep(): Promise<void> { 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]) { if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
this.onMessage(CREATION_SEALED_WAITING_MESSAGE); this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
return; return;
@@ -916,13 +1410,60 @@ export class PhaseRuntime {
const flow = parseCreationFlow( const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG), this.blackboard.getContentByTag(CREATION_FLOW_TAG),
); );
const accepted = parseAcceptedUnits( const accepted = this.readAcceptedStepIds();
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ?? if (!flow) {
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), debugLog("step", "暂无可用流程,等待点选或改编排");
await this.enterCreationStepPickWait();
return;
}
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
); );
const pending = nextPendingStep(flow, accepted); });
if (!pending || !flow) { if (autoReady.length > 0 && autoReady.length === ready.length) {
debugLog("step", "暂无下一步,交给总管"); 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();
}
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(),
};
}
private clearProposedStep(): void {
this.session = { this.session = {
...this.session, ...this.session,
slots: { slots: {
@@ -930,107 +1471,6 @@ export class PhaseRuntime {
[CREATION_PROPOSED_STEP_TAG]: undefined, [CREATION_PROPOSED_STEP_TAG]: undefined,
}, },
}; };
if (this.mainAgent) {
await this.runMainAgent();
} else {
this.onMessage(
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
);
}
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 */
}
}
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 } : {}),
};
this.blackboard.write({
tag: CREATION_PROPOSED_STEP_TAG,
content: JSON.stringify(snapshot),
source: "runtime",
});
this.session = {
...this.session,
slots: {
...this.session.slots,
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
},
};
const decision = createDecision({
action: "run_worker",
workerId: "design-step",
reason,
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
requiresApproval: !(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 { private applyProposedStepParams(params: CreationFlowStepParams): void {
const raw = const raw =
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string" (typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
@@ -1807,6 +2265,10 @@ export class PhaseRuntime {
flowRaw, flowRaw,
currentStepName, currentStepName,
acceptedStepNames: accepted, acceptedStepNames: accepted,
inheritExisting: isInheritExistingFlag(
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
),
}); });
if (!binding?.opening?.trim()) return false; if (!binding?.opening?.trim()) return false;
@@ -1952,7 +2414,9 @@ export class PhaseRuntime {
const flow = parseCreationFlow( const flow = parseCreationFlow(
this.blackboard.getContentByTag(CREATION_FLOW_TAG), 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 || "(无待执行步骤)"; current = pending ? stepUnitId(pending) : current || "(无待执行步骤)";
this.blackboard.write({ this.blackboard.write({
tag: CREATION_CURRENT_STEP_TAG, tag: CREATION_CURRENT_STEP_TAG,
@@ -2209,14 +2673,44 @@ 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> { private async runMainAgent(): Promise<void> {
if (!this.mainAgent || this.session.phase !== "running") { if (!this.mainAgent || this.session.phase !== "running") {
return; return;
} }
if (this.hasCreationFlow()) {
debugLog("step", "已有创作流程,等待点选节点");
await this.awaitCreationStepPick();
return;
}
debugLog("step", "总管开始"); debugLog("step", "总管开始");
const availableWorkers = await this.resolveAvailableWorkers(); const availableWorkers = await this.resolveAvailableWorkers();
try {
const decision = await this.mainAgent.runToolLoop( const decision = await this.mainAgent.runToolLoop(
{ {
session: this.session, session: this.session,
@@ -2250,24 +2744,29 @@ export class PhaseRuntime {
}, },
}, },
); );
await this.commitMainAgentDecision(decision);
debugLog( } catch (err) {
"step", const reason = err instanceof Error ? err.message : String(err);
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`, 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,
}),
); );
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`); return;
}
const result = this.commitEvent({ debugLog("step", `总管失败 ${reason.slice(0, 120)}`);
type: "main_agent_decision_created", await this.failRun(reason);
payload: { decision },
});
if (result.error) {
this.onMessage(result.error);
} }
await this.processEffects(result.effects);
await this.maybeRunMainAgent(result.effects);
} }
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */ /** 总管 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") { if (reason?.kind === "next_intent") {
return { return {
actorType: "user", actorType: "user",
@@ -471,6 +480,14 @@ export function buildFocus(
if (reason?.kind === "review_artifact") { if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
if (art?.workerId === "design-flow") {
return {
actorType: "user",
actorLabel: "你",
action: "点选要做的节点",
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
};
}
const copy = reviewComposerCopy(art?.workerId, { const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length), hasQuestions: Boolean(reason.questions?.length),
outputTags: art?.outputTags, outputTags: art?.outputTags,
+13 -2
View File
@@ -123,6 +123,17 @@ export function formatAgentDisplayTitle(detail?: string): string {
return "编排器"; 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 产物种类变化 */ /** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
export type ReviewComposerCopy = { export type ReviewComposerCopy = {
/** 底栏短芯片 */ /** 底栏短芯片 */
@@ -179,8 +190,8 @@ export function reviewComposerCopy(
taskLabel: "开场", taskLabel: "开场",
taskTitle: "选定开场白", taskTitle: "选定开场白",
hint: hasQuestions hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。" ? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。", : "同意就选定此开场;要改开场白就写意见。",
submitLabel: "按意见修改", submitLabel: "按意见修改",
acceptLabel: "选定此开场", acceptLabel: "选定此开场",
placeholder: "修改意见…", placeholder: "修改意见…",
+317 -25
View File
@@ -81,8 +81,10 @@ import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js"; import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import { import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG, CREATION_ACCEPTED_UNITS_TAG,
isDesignDiskWorker, isDesignDiskWorker,
parseAcceptedContentStore,
parseAcceptedUnits, parseAcceptedUnits,
SLOT_CREATION_ACCEPTED_UNITS, SLOT_CREATION_ACCEPTED_UNITS,
} from "../skills/creation-units.js"; } from "../skills/creation-units.js";
@@ -104,18 +106,26 @@ import {
CREATION_FLOW_TAG, CREATION_FLOW_TAG,
CREATION_PROPOSED_STEP_TAG, CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG, CREATION_SELECTED_RECIPE_TAG,
DESIGN_FLOW_WORKER_ID,
creationFlowFromRecipeSeed, creationFlowFromRecipeSeed,
findRecipeCatalogEntry, findRecipeCatalogEntry,
formatCreationFlowForUser, formatCreationFlowForUser,
findModuleByName,
findStepByRef,
type FlowStepTitleHint,
isProgressPointerTag, isProgressPointerTag,
loadRecipeCatalog, loadRecipeCatalog,
loadRecipeDetail, loadRecipeDetail,
loadModuleCatalog,
parseCreationFlow, parseCreationFlow,
parseSelectedRecipeRef, parseSelectedRecipeRef,
resolveDesignStepBinding, resolveDesignStepBinding,
stepUnitId, stepUnitId,
stringifyCreationFlow, stringifyCreationFlow,
stripReviseSteps,
type CreationFlow,
type CreationFlowUserView, type CreationFlowUserView,
type ModuleCatalog,
type RecipeCatalogEntry, type RecipeCatalogEntry,
} from "../skills/creation-flow.js"; } from "../skills/creation-flow.js";
import { loadSkill } from "../skills/loader.js"; import { loadSkill } from "../skills/loader.js";
@@ -260,6 +270,8 @@ export type SessionAction =
| { type: "approve"; label: string } | { type: "approve"; label: string }
| { type: "accept"; label: string } | { type: "accept"; label: string }
| { type: "reject"; label: string } | { type: "reject"; label: string }
| { type: "replan"; label: string }
| { type: "leave_step"; label: string }
| { type: "run_outline"; label: string } | { type: "run_outline"; label: string }
| { type: "finish"; label: string }; | { type: "finish"; label: string };
@@ -297,6 +309,8 @@ export type ProposedNextStepView = {
/** 步骤怎么跑:revise = 回头修改既有产物 */ /** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise"; mode?: "fresh" | "revise";
revises?: string; revises?: string;
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
closer?: boolean;
}; };
export type ReviewArtifactView = { export type ReviewArtifactView = {
@@ -325,6 +339,8 @@ type ManagedSession = {
branchState: MessageBranchState; branchState: MessageBranchState;
/** 当前能力包可选初始配方(用户手动选) */ /** 当前能力包可选初始配方(用户手动选) */
recipeOptions?: RecipeCatalogEntry[]; recipeOptions?: RecipeCatalogEntry[];
/** 模块目录(分层图 / 可反复增殖) */
moduleCatalog?: ModuleCatalog | null;
/** 开局模块 opening,缓存给意图页 */ /** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null; openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */ /** 当前 LLM 请求的取消器(停止并重试) */
@@ -540,6 +556,7 @@ export class SessionManager {
if (bookId) { if (bookId) {
this.activeBookSessions.set(bookId, id); this.activeBookSessions.set(bookId, id);
appendBookSession(bookId, id); appendBookSession(bookId, id);
this.persist(managed);
} }
return this.toView(id); return this.toView(id);
} }
@@ -557,7 +574,9 @@ export class SessionManager {
} }
get(id: string): SessionView | null { 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); return this.toView(id);
} }
@@ -825,7 +844,8 @@ export class SessionManager {
kind !== "review_artifact" && kind !== "review_artifact" &&
kind !== "approve_step" && kind !== "approve_step" &&
kind !== "revision" && kind !== "revision" &&
kind !== "next_intent" kind !== "next_intent" &&
kind !== "pick_creation_step"
) { ) {
return; return;
} }
@@ -1079,8 +1099,7 @@ export class SessionManager {
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 发送消息失败", detail); console.error("[会话] 发送消息失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); await this.recoverFailedRun(s, detail);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} }
this.syncCreationDialogue(s); this.syncCreationDialogue(s);
this.persist(s); this.persist(s);
@@ -1315,8 +1334,7 @@ export class SessionManager {
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止并重试失败", detail); console.error("[会话] 停止并重试失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail))); await this.recoverFailedRun(s, detail);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} }
this.syncCreationDialogue(s); this.syncCreationDialogue(s);
this.persist(s); this.persist(s);
@@ -1462,7 +1480,7 @@ export class SessionManager {
try { try {
const owned = await this.runExclusive(id, s, async () => { const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") { if (waiting?.kind === "approve_step") {
await s.runtime.rejectStep(reason ?? "用户暂不执行"); await s.runtime.leaveCreationStep();
} else if (waiting?.kind === "review_artifact") { } else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来"); await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else { } else {
@@ -1476,9 +1494,174 @@ export class SessionManager {
s.messages.push(this.msg("system", formatRuntimeError(detail))); s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages); if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
} }
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id); 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> { async runOutline(id: string): Promise<SessionView> {
const s = this.require(id); const s = this.require(id);
try { try {
@@ -1586,6 +1769,7 @@ export class SessionManager {
this.activeBookSessions.set(params.bookId, params.sessionId); this.activeBookSessions.set(params.bookId, params.sessionId);
appendBookSession(params.bookId, params.sessionId); appendBookSession(params.bookId, params.sessionId);
await this.refreshRecipeOptions(managedRef); await this.refreshRecipeOptions(managedRef);
runtime.recoverOrphanedRun();
this.persist(managedRef); this.persist(managedRef);
await this.maybeKickAgentAfterDemand(runtime); 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( private enrichDisplayMessages(
session: RuntimeSession, session: RuntimeSession,
messages: ChatMessage[], messages: ChatMessage[],
@@ -2202,11 +2400,21 @@ export class SessionManager {
} else { } else {
actions.push({ type: "approve", label: "确认执行" }); actions.push({ type: "approve", label: "确认执行" });
} }
actions.push({ type: "reject", label: "暂不执行" }); actions.push({ type: "reject", label: "返回节点" });
actions.push({ actions.push({
type: "send_message", type: "send_message",
label: "说明意见", 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") { } else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。"); hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
@@ -2220,6 +2428,16 @@ export class SessionManager {
const copy = reviewComposerCopy(art?.workerId, { const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length), hasQuestions: Boolean(reason.questions?.length),
}); });
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: "accept", label: copy.acceptLabel });
actions.push({ type: "reject", label: "不接受" }); actions.push({ type: "reject", label: "不接受" });
actions.push({ actions.push({
@@ -2227,8 +2445,13 @@ export class SessionManager {
label: copy.submitLabel, label: copy.submitLabel,
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`, placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
}); });
}
if (reason.questions?.length) { 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: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_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) { if (s.bookId) {
this.persist(s); this.persist(s);
} }
@@ -2330,7 +2557,12 @@ export class SessionManager {
effectiveStartupMode(activeSnap) === "agent-first" effectiveStartupMode(activeSnap) === "agent-first"
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT ? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
: undefined, : undefined,
reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()), reviewArtifact: buildReviewArtifactView(
session,
messages,
s.runtime.getBlackboard(),
s.moduleCatalog,
),
workerSetView: buildWorkerSetUserView( workerSetView: buildWorkerSetUserView(
s.runtime.getBlackboard(), s.runtime.getBlackboard(),
s.runtime.getSession(), s.runtime.getSession(),
@@ -2338,6 +2570,7 @@ export class SessionManager {
creationFlowView: buildCreationFlowUserView( creationFlowView: buildCreationFlowUserView(
s.runtime.getBlackboard(), s.runtime.getBlackboard(),
session.slots[SLOT_CREATION_ACCEPTED_UNITS], session.slots[SLOT_CREATION_ACCEPTED_UNITS],
s.moduleCatalog,
), ),
proposedNextStep: proposedNextStep:
reason?.kind === "approve_step" reason?.kind === "approve_step"
@@ -2448,6 +2681,7 @@ export class SessionManager {
const skillName = s.runtime.getActiveSkill()?.name; const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) { if (!skillName) {
s.recipeOptions = []; s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null; s.openingGuide = null;
return; return;
} }
@@ -2455,13 +2689,19 @@ export class SessionManager {
const skill = await loadSkill(skillName); const skill = await loadSkill(skillName);
if (!skill.skillPackRoot) { if (!skill.skillPackRoot) {
s.recipeOptions = []; s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null; s.openingGuide = null;
return; return;
} }
const catalog = await loadRecipeCatalog(skill.skillPackRoot); const [catalog, modules] = await Promise.all([
loadRecipeCatalog(skill.skillPackRoot),
loadModuleCatalog(skill.skillPackRoot),
]);
s.recipeOptions = catalog?.recipes ?? []; s.recipeOptions = catalog?.recipes ?? [];
s.moduleCatalog = modules;
} catch { } catch {
s.recipeOptions = []; s.recipeOptions = [];
s.moduleCatalog = null;
} }
await this.ensureRecipeSeededFlow(s); await this.ensureRecipeSeededFlow(s);
await this.refreshOpeningGuide(s); await this.refreshOpeningGuide(s);
@@ -2489,11 +2729,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry); const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail); const seeded = creationFlowFromRecipeSeed(detail);
if (!seeded) return; if (!seeded) return;
board.write({ await this.commitSeededCreationFlow(s, seeded);
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
} catch (err) { } catch (err) {
console.error("[会话] 配方预置失败", err); console.error("[会话] 配方预置失败", err);
} }
@@ -2553,6 +2789,7 @@ export class SessionManager {
s: ManagedSession, s: ManagedSession,
reason: WaitingReason | undefined, reason: WaitingReason | undefined,
): { stepId: string; stepName: string; text: string } | null { ): { stepId: string; stepName: string; text: string } | null {
if (reason?.kind === "pick_creation_step") return null;
if (reason?.kind === "worker_questions") { if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions); const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) { if (isModuleOpeningQuestions(qs)) {
@@ -2584,6 +2821,44 @@ export class SessionManager {
return s.openingGuide ?? null; 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( private async applySelectedRecipe(
s: ManagedSession, s: ManagedSession,
recipeId: string, recipeId: string,
@@ -2606,7 +2881,7 @@ export class SessionManager {
source: "user", source: "user",
}); });
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow) // 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim(); const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!existingFlow) { if (!existingFlow) {
const skillName = s.runtime.getActiveSkill()?.name; const skillName = s.runtime.getActiveSkill()?.name;
@@ -2618,11 +2893,7 @@ export class SessionManager {
const detail = await loadRecipeDetail(packRoot, entry); const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail); const seeded = creationFlowFromRecipeSeed(detail);
if (seeded) { if (seeded) {
board.write({ await this.commitSeededCreationFlow(s, seeded);
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
} }
} }
} catch (err) { } catch (err) {
@@ -2652,6 +2923,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
function buildCreationFlowUserView( function buildCreationFlowUserView(
blackboard: Blackboard, blackboard: Blackboard,
acceptedRaw?: unknown, acceptedRaw?: unknown,
catalog?: ModuleCatalog | null,
): CreationFlowUserView | undefined { ): CreationFlowUserView | undefined {
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim(); const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!raw) return undefined; if (!raw) return undefined;
@@ -2662,7 +2934,24 @@ function buildCreationFlowUserView(
const accepted = parseAcceptedUnits( const accepted = parseAcceptedUnits(
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG), 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( function readProposedNextStep(
@@ -2708,6 +2997,7 @@ function readProposedNextStep(
intent: typeof doc.intent === "string" ? doc.intent : undefined, intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined, mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined, revises: typeof doc.revises === "string" ? doc.revises : undefined,
closer: doc.closer === true ? true : undefined,
}; };
} catch { } catch {
return undefined; return undefined;
@@ -2767,6 +3057,7 @@ function buildReviewArtifactView(
session: RuntimeSession, session: RuntimeSession,
messages: ChatMessage[], messages: ChatMessage[],
blackboard: Blackboard, blackboard: Blackboard,
catalog?: ModuleCatalog | null,
): ReviewArtifactView | undefined { ): ReviewArtifactView | undefined {
if (session.waitingReason?.kind !== "review_artifact") return undefined; if (session.waitingReason?.kind !== "review_artifact") return undefined;
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId); const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
@@ -2869,6 +3160,7 @@ function buildReviewArtifactView(
? buildCreationFlowUserView( ? buildCreationFlowUserView(
blackboard, blackboard,
session.slots[SLOT_CREATION_ACCEPTED_UNITS], session.slots[SLOT_CREATION_ACCEPTED_UNITS],
catalog,
) )
: undefined; : undefined;
@@ -2896,7 +3188,7 @@ function formatRuntimeError(detail: string): string {
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) { if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
return ( return (
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," + "[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
"或点击「测试连接」验证。" "或点击「探测」验证。"
); );
} }
if (detail.startsWith("LLM request failed")) { if (detail.startsWith("LLM request failed")) {
+38 -11
View File
@@ -4,9 +4,13 @@ import {
deleteApiProfile, deleteApiProfile,
getApiProfile, getApiProfile,
listApiProfiles, listApiProfiles,
testApiProfile, probeAndSaveProfileCapabilities,
updateApiProfile, updateApiProfile,
} from "../config/api-profiles.js"; } from "../config/api-profiles.js";
import {
deliveryModeLabel,
resolveStructuredDeliveryMode,
} from "../llm/capabilities.js";
import { import {
ensureActiveProfileDefault, ensureActiveProfileDefault,
loadAppSettings, loadAppSettings,
@@ -120,12 +124,14 @@ export async function handleSettingsApi(
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
model?: string; model?: string;
reasoningEffort?: string;
}; };
const profile = createApiProfile({ const profile = createApiProfile({
name: body.name ?? "新配置", name: body.name ?? "新配置",
baseUrl: body.baseUrl ?? "https://api.deepseek.com", baseUrl: body.baseUrl ?? "https://api.deepseek.com",
apiKey: body.apiKey ?? "", apiKey: body.apiKey ?? "",
model: body.model ?? "deepseek-v4-pro", model: body.model ?? "deepseek-v4-pro",
reasoningEffort: body.reasoningEffort,
}); });
const activate = (body as { activate?: boolean }).activate === true; const activate = (body as { activate?: boolean }).activate === true;
if (activate) { if (activate) {
@@ -136,19 +142,34 @@ export async function handleSettingsApi(
return true; return true;
} }
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/); const profileMatch = pathname.match(
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
);
if (profileMatch) { if (profileMatch) {
const id = decodeURIComponent(profileMatch[1]); const id = decodeURIComponent(profileMatch[1]);
const isTest = profileMatch[2] === "/test"; const suffix = profileMatch[2];
if (isTest && req.method === "POST") { if (suffix === "/probe-capabilities" && req.method === "POST") {
const profile = getApiProfile(id); try {
if (!profile) { const profile = await probeAndSaveProfileCapabilities(id);
json(res, 404, { error: "配置不存在" }); const mode = resolveStructuredDeliveryMode(profile.capabilities);
return true; 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; return true;
} }
@@ -168,10 +189,16 @@ export async function handleSettingsApi(
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
model?: string; model?: string;
reasoningEffort?: string;
}; };
try { try {
const profile = updateApiProfile(id, body); 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) { } catch (err) {
json(res, 404, { json(res, 404, {
error: err instanceof Error ? err.message : "更新失败", error: err instanceof Error ? err.message : "更新失败",
+41
View File
@@ -123,6 +123,18 @@ const server = createServer(async (req, res) => {
return; 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") { if (req.method === "POST" && sub === "/messages") {
const body = JSON.parse(await readBody(req)) as { const body = JSON.parse(await readBody(req)) as {
text?: string; text?: string;
@@ -445,6 +457,10 @@ const server = createServer(async (req, res) => {
action?: string; action?: string;
stepParams?: Record<string, unknown>; stepParams?: Record<string, unknown>;
openingIndex?: number; openingIndex?: number;
reason?: string;
stepId?: string;
moduleName?: string;
reenter?: boolean;
}; };
let view; let view;
try { try {
@@ -474,6 +490,31 @@ const server = createServer(async (req, res) => {
case "reject": case "reject":
view = await sessionManager.reject(sessionId); view = await sessionManager.reject(sessionId);
break; 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": case "run_outline":
view = await sessionManager.runOutline(sessionId); view = await sessionManager.runOutline(sessionId);
break; break;
+3 -13
View File
@@ -2,7 +2,7 @@
* 上下文片段产物(context-fragment.v1)解析与展示辅助。 * 上下文片段产物(context-fragment.v1)解析与展示辅助。
* 规范:docs/context-fragment-design.md * 规范:docs/context-fragment-design.md
*/ */
import { extractJsonObjectText } from "./worker-set-parse.js"; import { tryParseJsonDoc } from "../parse/json-doc.js";
import { import {
normalizeQuestions, normalizeQuestions,
type QuestionItem, type QuestionItem,
@@ -75,15 +75,10 @@ function normalizeStability(v: unknown): ContextFragmentStability | undefined {
export function parseContextFragment(raw: unknown): ContextFragment | undefined { export function parseContextFragment(raw: unknown): ContextFragment | undefined {
let row: Record<string, unknown> | null = null; let row: Record<string, unknown> | null = null;
if (typeof raw === "string") { if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim(); const parsed = tryParseJsonDoc(raw);
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>; row = parsed as Record<string, unknown>;
} }
} catch {
return undefined;
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) { } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>; row = raw as Record<string, unknown>;
} }
@@ -278,15 +273,10 @@ export function extractFragmentAskSidecar(raw: unknown): {
} { } {
let row: Record<string, unknown> | null = null; let row: Record<string, unknown> | null = null;
if (typeof raw === "string") { if (typeof raw === "string") {
const text = extractJsonObjectText(raw) ?? raw.trim(); const parsed = tryParseJsonDoc(raw);
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row = parsed as Record<string, unknown>; row = parsed as Record<string, unknown>;
} }
} catch {
return { questions: [], assessment: "" };
}
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) { } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
row = raw as Record<string, unknown>; 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 共用入口)。 * 解析本次 worker 的验收模式(创作 / run 共用入口)。
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed * - design-flow → no_confirmation(图上点节点即确认;底栏输入即改编排)
* - design-step / opening-generator 磁盘创作 worker → 始终 user_confirmed
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收) * - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation * - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
* - 未声明 acceptance:创作默认确认 * - 未声明 acceptance:创作默认确认
@@ -55,6 +56,7 @@ export function resolveAcceptanceModeForWorker(params: {
workerId: string; workerId: string;
}): AcceptanceMode { }): AcceptanceMode {
const workerId = params.workerId.trim(); const workerId = params.workerId.trim();
if (workerId === "design-flow") return "no_confirmation";
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") { if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
return "user_confirmed"; return "user_confirmed";
} }
@@ -95,7 +97,7 @@ export async function resolveRunnableWorker(params: {
const acceptedRaw = const acceptedRaw =
params.session.slots?.creationAcceptedUnits ?? params.session.slots?.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位"); params.blackboard.getContentByTag("创作.已验收单位");
const { parseAcceptedSteps } = await import("./creation-flow.js"); const { parseAcceptedSteps, isInheritExistingFlag } = await import("./creation-flow.js");
const withCtx = await loadWorkerSkillWithContext( const withCtx = await loadWorkerSkillWithContext(
params.skillPackName, params.skillPackName,
params.workerId, params.workerId,
@@ -106,6 +108,9 @@ export async function resolveRunnableWorker(params: {
acceptedStepNames: parseAcceptedSteps(acceptedRaw), acceptedStepNames: parseAcceptedSteps(acceptedRaw),
selectedRecipeRef, selectedRecipeRef,
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag), filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
inheritExisting: isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
),
}, },
); );
return { 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; selectedRecipeRef?: string | null;
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */ /** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
filledArtifactTags?: readonly string[]; filledArtifactTags?: readonly string[];
/** 用户点已完成节点重进 */
inheritExisting?: boolean;
}, },
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> { ): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot); const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
@@ -526,6 +528,7 @@ export async function loadWorkerSkillWithContext(
currentStepName: opts?.currentStepName, currentStepName: opts?.currentStepName,
acceptedStepNames: opts?.acceptedStepNames, acceptedStepNames: opts?.acceptedStepNames,
skillsRoot, skillsRoot,
inheritExisting: opts?.inheritExisting,
}); });
if (binding) { if (binding) {
const inheritTag = binding.inheritTag?.trim() || ""; const inheritTag = binding.inheritTag?.trim() || "";
@@ -536,8 +539,11 @@ export async function loadWorkerSkillWithContext(
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。` ? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
: ""; : "";
const paramsBlock = isPriorArtifactModule(binding.module) const paramsBlock = isPriorArtifactModule(binding.module)
? formatPriorArtifactContext(binding.step.params) ? formatPriorArtifactContext(
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`; binding.step.params,
binding.prototypeSuggestion,
)
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问编排层已定的参数)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
let rosterBlock = ""; let rosterBlock = "";
const depProjection = const depProjection =
binding.module.id === "context-order" ? "index" : undefined; 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); if (flow.status === "closed") return stringifyCreationFlow(flow);
return stringifyCreationFlow({ ...flow, status: "closed" }); 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 { parse as parseYaml } from "yaml";
import { tryParseJsonDoc } from "../parse/json-doc.js";
import { import {
inferPlaySlotsFromWorkers, inferPlaySlotsFromWorkers,
mergeWorkersWithPlaySlots, mergeWorkersWithPlaySlots,
@@ -400,6 +401,10 @@ export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | n
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */ /** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
export function extractJsonObjectText(raw: string): string | null { 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(); const text = raw.trim();
if (!text) return null; if (!text) return null;
if (text.startsWith("{")) { 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 的消息」中最新 keepLatest 条的全文;
* 更早的消息删除 contextTrace 字段(消息本身保留)。 * 更早的消息删除 contextTrace 字段(消息本身保留)。
* design-flow 痕迹钉住不删(keep=0 仍全部清除)。
*/ */
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>( export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
messages: T[], messages: T[],
@@ -88,12 +95,15 @@ export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>
return rest as T; return rest as T;
}); });
} }
const withTraceIdx: number[] = []; const droppableIdx: number[] = [];
for (let i = 0; i < messages.length; i++) { 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; if (droppableIdx.length <= keep) return messages;
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep)); const drop = new Set(droppableIdx.slice(0, droppableIdx.length - keep));
return messages.map((m, i) => { return messages.map((m, i) => {
if (!drop.has(i) || !m.contextTrace) return m; if (!drop.has(i) || !m.contextTrace) return m;
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace }; const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
+30 -1
View File
@@ -60,6 +60,13 @@ export type WaitingReason =
kind: "next_intent"; kind: "next_intent";
afterWorkerId?: string; afterWorkerId?: string;
afterUnitId?: string; afterUnitId?: string;
}
| {
/**
* DAG 已在:按依赖层级点选节点。依赖未齐的不能点;
* 可反复能力以 role=prototype 槽位呈现;点原型增殖 instance 再进去。
*/
kind: "pick_creation_step";
}; };
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */ /** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
@@ -232,6 +239,26 @@ export type RuntimeEvent =
type: "user_rejected_next_step"; type: "user_rejected_next_step";
payload: { decisionId: string; reason?: string }; 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"; type: "worker_started";
payload: { payload: {
@@ -331,10 +358,12 @@ export type PhaseEffect =
| { type: "resume_worker" } | { type: "resume_worker" }
| { type: "run_programmatic_review"; artifactId: string } | { type: "run_programmatic_review"; artifactId: string }
| { type: "emit_message"; message: string } | { type: "emit_message"; message: string }
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */ /** 根据流程与用户下一步意向,提案下一节点或停在分层图上等点选 */
| { type: "propose_next_creation_step" } | { type: "propose_next_creation_step" }
/** 选定开场白:落库、关 DAG、标记创作收口 */ /** 选定开场白:落库、关 DAG、标记创作收口 */
| { type: "seal_creation_opening" } | { type: "seal_creation_opening" }
/** 再编排:解开开场收口、把 DAG 改回 open */
| { type: "unseal_creation_opening" }
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */ /** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
| { type: "run_play_turn" } | { type: "run_play_turn" }
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */ /** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
+126 -63
View File
@@ -1,6 +1,7 @@
import type { Blackboard } from "../blackboard/blackboard.js"; import type { Blackboard } from "../blackboard/blackboard.js";
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.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 { loadWorkerSkillWithContext } from "../skills/loader.js";
import { filterInputsForRolePerspective } from "../skills/worker-llm.js"; import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
import { collectUserInputTranscript } from "../intake/intake.js"; import { collectUserInputTranscript } from "../intake/intake.js";
@@ -14,6 +15,10 @@ import {
looksLikeProseNotSpec, looksLikeProseNotSpec,
parseWorkerSetYaml, parseWorkerSetYaml,
} from "../skills/worker-set-parse.js"; } from "../skills/worker-set-parse.js";
import {
selectJsonPayload,
tryParseJsonDoc,
} from "../parse/json-doc.js";
import { assembleWorkerContext } from "../skills/context-segments.js"; import { assembleWorkerContext } from "../skills/context-segments.js";
import { isPlayLayerActive } from "../skills/play-turn.js"; import { isPlayLayerActive } from "../skills/play-turn.js";
import { loadAppSettings } from "../config/settings.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 { assemblePlayWorkerMessages } from "../preset/play-frame.js";
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js"; import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
import { PRESENT_TAG } from "../skills/present-packet.js"; import { PRESENT_TAG } from "../skills/present-packet.js";
import {
coerceToFlatArtifact,
flatArtifactToFragmentDoc,
} from "../skills/flat-artifact.js";
import { import {
gmChanceToolsEnabled, gmChanceToolsEnabled,
GM_CHANCE_HARNESS_INSTRUCTION, GM_CHANCE_HARNESS_INSTRUCTION,
@@ -36,7 +45,10 @@ import {
isUsableContextFragment, isUsableContextFragment,
looksLikeFragmentDoc, looksLikeFragmentDoc,
} from "../skills/context-fragment.js"; } from "../skills/context-fragment.js";
import { isProgressPointerTag } from "../skills/creation-flow.js"; import {
isProgressPointerTag,
looksLikeCreationFlowDoc,
} from "../skills/creation-flow.js";
import { import {
CONTEXT_ORDER_TAG, CONTEXT_ORDER_TAG,
parseContextOrder, parseContextOrder,
@@ -93,28 +105,19 @@ const WORKER_OUTPUT_INSTRUCTION = `
--- ---
## 运行时输出协议(必须遵守) ## 运行时输出协议
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段: 输出 **一个 JSON 对象**(由程序按模型能力用 schema / tool / json_object 约束投递)。
{
"outputs": { "<outputTag>": "<内容字符串>" },
"summary": "50字以内产物摘要",
"askUser": null 或 问题数组
}
askUser 每项可为: 按本步 SKILL 规定的产物形状写。有正文的技能稿按该步 \`正文\` 对象写(柱下到条目/诊断三元组),不要把业务字段改成一段 \`sections[].text\`。
- 字符串:"需要用户补充的问题" 「设计.创作流程」必须是带 \`steps\` 数组的 DAG,worker 集必须是带 \`workers\` 的规格。
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
- outputs 的 key 必须是要求的 outputTags 也兼容:
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML 1. 直接输出本步产物对象(创作流程 / context-fragment.v1 / worker 集等)
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针 2. \`{ "outputs": { "<本步 tag>": { … } }, "summary":"…", "askUser": null }\`(values 用对象)
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问 不要写入进度指针。追问用 \`questions\`(或旧 \`追问.题目\`);\`questions: []\` 表示可验收。
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser 「开放问题」留给后续步。残稿也要交。summary 用于界面展示。`;
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
- summary 用于界面展示`;
function slotValueForTag( function slotValueForTag(
tag: string, 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( function recoverFragmentOutputs(
obj: Record<string, unknown>, obj: Record<string, unknown>,
outputs: Record<string, string>, outputs: Record<string, string>,
@@ -200,7 +210,7 @@ function recoverFragmentOutputs(
): void { ): void {
const target = productOutputTags(outputTags)[0]; const target = productOutputTags(outputTags)[0];
if (!target) return; if (!target) return;
if (outputs[target] && isUsableContextFragment(outputs[target])) return; if (existingOutputIsProduct(outputs[target])) return;
const outputsRaw = const outputsRaw =
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs) obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
@@ -212,7 +222,11 @@ function recoverFragmentOutputs(
if (key === target) continue; if (key === target) continue;
const text = stringifyOutputValue(val); const text = stringifyOutputValue(val);
if (!text) continue; if (!text) continue;
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) { if (
isUsableContextFragment(text) ||
looksLikeFragmentDoc(val) ||
looksLikeCreationFlowDoc(val)
) {
outputs[target] = text; outputs[target] = text;
return; return;
} }
@@ -227,6 +241,13 @@ function recoverFragmentOutputs(
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) { if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
const source = looksLikeFragmentDoc(obj) ? obj : rest; const source = looksLikeFragmentDoc(obj) ? obj : rest;
outputs[target] = JSON.stringify(source); 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( function parseWorkerResponse(
raw: string, raw: string,
outputTags: string[], outputTags: string[],
): WorkerRunResult { ): WorkerRunResult {
let parsed: unknown; const parsed = tryParseJsonDoc(raw);
try { if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
parsed = JSON.parse(raw);
} catch {
const target = productOutputTags(outputTags)[0] ?? outputTags[0]; const target = productOutputTags(outputTags)[0] ?? outputTags[0];
if (target && looksLikeStructuredDraft(raw)) { if (target && looksLikeStructuredDraft(raw)) {
return finalizeParsedOutputs( return finalizeParsedOutputs(
@@ -322,17 +355,43 @@ function parseWorkerResponse(
}; };
} }
if (!parsed || typeof parsed !== "object") { const obj = parsed as Record<string, unknown>;
throw new Error("Worker 返回了无效 JSON");
// 创作流程 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 outputsRaw = obj.outputs;
const outputs: Record<string, string> = {}; 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)) { 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); recoverFragmentOutputs(obj, outputs, outputTags);
@@ -484,25 +543,6 @@ function extractQuestionsFromText(text: string): string[] {
return []; 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> { export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
const workerId = resolveWorkerId(params.workerId); const workerId = resolveWorkerId(params.workerId);
let worker: ParsedWorkerSkill; let worker: ParsedWorkerSkill;
@@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
params.slots.creationAcceptedUnits ?? params.slots.creationAcceptedUnits ??
params.blackboard.getContentByTag("创作.已验收单位"); params.blackboard.getContentByTag("创作.已验收单位");
let acceptedStepNames: string[] = []; let acceptedStepNames: string[] = [];
let inheritExisting = false;
try { try {
const { parseAcceptedSteps } = await import("../skills/creation-flow.js"); const { parseAcceptedSteps, isInheritExistingFlag } = await import(
"../skills/creation-flow.js"
);
acceptedStepNames = parseAcceptedSteps(acceptedRaw); acceptedStepNames = parseAcceptedSteps(acceptedRaw);
inheritExisting = isInheritExistingFlag(
params.blackboard.getContentByTag("创作.继承修改"),
);
} catch { } catch {
acceptedStepNames = []; acceptedStepNames = [];
} }
@@ -538,6 +584,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
acceptedStepNames, acceptedStepNames,
selectedRecipeRef, selectedRecipeRef,
filledArtifactTags, filledArtifactTags,
inheritExisting,
}, },
); );
worker = loaded.worker; worker = loaded.worker;
@@ -616,20 +663,36 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
stream: params.stream, stream: params.stream,
caller: `worker:${worker.id}`, caller: `worker:${worker.id}`,
}); });
return parseWorkerResponse(harness.content, worker.outputTags); return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
} }
const result = await completeWorkerPreferStream( const streamCallbacks: StreamCallbacks | undefined = params.stream
params.llm, ? {
messages, onReasoningDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
{ onContentDelta: (delta) => params.stream?.onOutputDelta?.(delta),
responseFormat: "json_object", }
caller: `worker:${worker.id}`, : undefined;
},
params.stream,
);
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 供单测 */ /** @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"]); 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", () => { it("is a no-op when under the limit", () => {
const messages = [msg("1", true), msg("2", true)]; const messages = [msg("1", true), msg("2", true)];
const pruned = pruneContextTraces(messages, 5); const pruned = pruneContextTraces(messages, 5);
+393 -34
View File
@@ -6,6 +6,8 @@ import {
artifactTagForStep, artifactTagForStep,
extractModuleOpening, extractModuleOpening,
formatCreationFlowForUser, formatCreationFlowForUser,
deriveFlowStepTitle,
titleFromSuggestion,
formatFlowProgressForAgent, formatFlowProgressForAgent,
formatModuleCatalogForAgent, formatModuleCatalogForAgent,
formatRecipeCatalogForAgent, formatRecipeCatalogForAgent,
@@ -18,6 +20,13 @@ import {
mergeCreationFlowPreservingAccepted, mergeCreationFlowPreservingAccepted,
needsFlowExpansion, needsFlowExpansion,
nextPendingStep, nextPendingStep,
listReadySteps,
computeStepLayers,
spawnRepeatableCreationStep,
spawnInstanceFromPrototype,
hasSelectableCreationWork,
removeUnstartedInstance,
looksLikeCreationFlowDoc,
parseCreationFlow, parseCreationFlow,
pickRecordedStepId, pickRecordedStepId,
parseModuleCatalog, parseModuleCatalog,
@@ -29,6 +38,7 @@ import {
shouldSkipModuleOpening, shouldSkipModuleOpening,
isProgressPointerTag, isProgressPointerTag,
stringifyCreationFlow, stringifyCreationFlow,
stripReviseSteps,
creationFlowFromRecipeSeed, creationFlowFromRecipeSeed,
validateCreationFlow, validateCreationFlow,
validateStepReadyToRun, validateStepReadyToRun,
@@ -51,6 +61,10 @@ modules:
declaration: 锚定实例 declaration: 锚定实例
artifact: 设计.具体实例 artifact: 设计.具体实例
repeatable: true repeatable: true
- name: 开场白与开场变量
declaration: 收口
artifact: 设计.开场白与开场变量
closer: true
`)!; `)!;
describe("creation-flow", () => { describe("creation-flow", () => {
@@ -145,7 +159,44 @@ describe("creation-flow", () => {
acceptedStepIds: ["美学纲领与交互范式"], acceptedStepIds: ["美学纲领与交互范式"],
}); });
expect(merged.restored).toEqual([]); 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", () => { it("allows duplicate capability names with distinct ids", () => {
@@ -185,6 +236,24 @@ describe("creation-flow", () => {
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式"); 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", () => { it("validates deps must appear earlier", () => {
const bad = parseCreationFlow(`{ const bad = parseCreationFlow(`{
"steps": [ "steps": [
@@ -250,13 +319,93 @@ describe("creation-flow", () => {
depends_on: [], depends_on: [],
declaration: "站位与体验契约", 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[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]?.runState).toBe("pending");
expect(view.steps[1]?.layer).toBe(1);
expect(view.steps[1]?.ready).toBe(false);
expect(view.steps[2]?.runState).toBe("pending"); 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", () => { it("marks accepted steps done and the next pending current", () => {
const flow = parseCreationFlow(`{ const flow = parseCreationFlow(`{
"brief": "网恋回合", "brief": "网恋回合",
@@ -272,9 +421,10 @@ describe("creation-flow", () => {
]); ]);
expect(view.steps.map((s) => s.runState)).toEqual([ expect(view.steps.map((s) => s.runState)).toEqual([
"done", "done",
"current", "pending",
"pending", "pending",
]); ]);
expect(view.steps[1]?.selectable).toBe(true);
}); });
it("loads world-simulator catalog and formats for agent", async () => { 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("何时不用"); expect(block).toContain("何时不用");
expect(block).toContain("规划产物字段"); expect(block).toContain("role=prototype");
expect(block).toContain("编排参数"); expect(block).toContain("编排参数");
expect(block).not.toContain("设计.美学纲领与交互范式"); expect(block).not.toContain("设计.美学纲领与交互范式");
}); });
@@ -403,6 +553,22 @@ steps:
).toBeNull(); ).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", () => { it("falls back to legacy hint when methodology absent", () => {
const detail = parseRecipeYaml( const detail = parseRecipeYaml(
` `
@@ -478,7 +644,7 @@ recipes:
expect(loaded.promptBody).toContain("美学纲领与交互范式"); expect(loaded.promptBody).toContain("美学纲领与交互范式");
expect(loaded.promptBody).toContain("已验收"); expect(loaded.promptBody).toContain("已验收");
expect(loaded.promptBody).toContain("禁止再排新建"); expect(loaded.promptBody).toContain("禁止再排新建");
expect(loaded.promptBody).toContain("mode=revise"); expect(loaded.promptBody).toContain("禁止排 mode=revise");
expect(loaded.promptBody).toContain("生成规则"); expect(loaded.promptBody).toContain("生成规则");
}); });
@@ -496,7 +662,36 @@ recipes:
expect(text).toContain("草案已有、尚未验收"); expect(text).toContain("草案已有、尚未验收");
expect(text).toContain("美学纲领与交互范式"); 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 () => { it("injects aesthetics-interaction module prompt into design-step", async () => {
@@ -637,7 +832,7 @@ recipes:
expect(missingView.steps[0]?.paramsMissing).toBeUndefined(); expect(missingView.steps[0]?.paramsMissing).toBeUndefined();
const block = formatModuleCatalogForAgent(catalog!); const block = formatModuleCatalogForAgent(catalog!);
expect(block).toContain("规划产物字段"); expect(block).toContain("role=prototype");
expect(block).toContain("target"); expect(block).toContain("target");
}); });
@@ -662,8 +857,7 @@ recipes:
acceptedStepNames: [], acceptedStepNames: [],
}, },
); );
expect(loaded.promptBody).toContain("【规划产物】"); expect(loaded.promptBody).toContain("【本步对象】");
expect(loaded.promptBody).not.toContain("勿再问「生成什么");
expect(loaded.promptBody).toContain("target: 怪物"); expect(loaded.promptBody).toContain("target: 怪物");
expect(loaded.promptBody).toContain("rule_id: monsters"); expect(loaded.promptBody).toContain("rule_id: monsters");
expect(loaded.worker.outputTags).toContain("设计.生成规则"); expect(loaded.worker.outputTags).toContain("设计.生成规则");
@@ -693,14 +887,22 @@ modules:
undefined, undefined,
{ {
flowRaw: JSON.stringify({ flowRaw: JSON.stringify({
steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }], steps: [
{
id: "生成规则#1",
name: "生成规则",
role: "instance",
from: "生成规则",
depends_on: [],
},
],
}), }),
currentStepName: "生成规则", currentStepName: "生成规则#1",
acceptedStepNames: [], 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", () => { it("nextPendingStep respects deps and accepted by id", () => {
@@ -780,7 +982,7 @@ modules:
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true); 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(`{ const flow = parseCreationFlow(`{
"status": "open", "status": "open",
"steps": [ "steps": [
@@ -795,10 +997,35 @@ modules:
revises: "实现机制", revises: "实现机制",
}); });
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
const stripped = stripReviseSteps(flow);
expect(stripped.steps.map((s) => s.id)).toEqual(["实现机制"]);
const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]); const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]);
expect(view.steps[1]?.mode).toBe("revise"); expect(view.steps).toHaveLength(1);
expect(view.steps[1]?.occurrence).toBeUndefined(); expect(view.steps[0]?.id).toBe("实现机制");
expect(view.steps[1]?.runState).toBe("current"); 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", () => { it("still rejects two fresh copies of a non-repeatable skill", () => {
@@ -849,7 +1076,7 @@ modules:
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true); 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(`{ const flow = parseCreationFlow(`{
"status": "open", "status": "open",
"steps": [ "steps": [
@@ -863,31 +1090,26 @@ modules:
} }
] ]
}`)!; }`)!;
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise"); expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBeUndefined();
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe( 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({ const flowRaw = JSON.stringify({
status: "open", status: "open",
steps: [ steps: [
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] }, { id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
{
id: "美学纲领与交互范式·改",
name: "美学纲领与交互范式",
mode: "revise",
revises: "美学纲领与交互范式",
depends_on: ["美学纲领与交互范式"],
},
], ],
}); });
const binding = await resolveDesignStepBinding({ const binding = await resolveDesignStepBinding({
skillPackRoot: "dialogue/world-simulator", skillPackRoot: "dialogue/world-simulator",
flowRaw, flowRaw,
currentStepName: "美学纲领与交互范式·改", currentStepName: "美学纲领与交互范式",
acceptedStepNames: ["美学纲领与交互范式"], acceptedStepNames: ["美学纲领与交互范式"],
inheritExisting: true,
}); });
expect(binding?.opening).toBeNull(); expect(binding?.opening).toBeNull();
expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式"); expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式");
@@ -898,16 +1120,153 @@ modules:
undefined, undefined,
{ {
flowRaw, flowRaw,
currentStepName: "美学纲领与交互范式·改", currentStepName: "美学纲领与交互范式",
acceptedStepNames: ["美学纲领与交互范式"], acceptedStepNames: ["美学纲领与交互范式"],
inheritExisting: true,
}, },
); );
expect(loaded.promptBody).toContain("【回头修改】"); expect(loaded.promptBody).toContain("【回头修改】");
expect(loaded.promptBody).not.toContain("程序开场"); expect(loaded.promptBody).not.toContain("程序开场");
expect(loaded.worker.name).toContain("回头修改"); expect(loaded.worker.name).toContain("回头修改");
expect(loaded.worker.inputTags).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( 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); ).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"; import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
describe("resolveAcceptanceModeForWorker", () => { describe("resolveAcceptanceModeForWorker", () => {
it("always confirms design-flow", () => { it("auto-continues design-flow without a confirm step", () => {
const mode = resolveAcceptanceModeForWorker({ const mode = resolveAcceptanceModeForWorker({
session: createSession(), session: createSession(),
blackboard: new Blackboard(), blackboard: new Blackboard(),
workerId: "design-flow", 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"); expect(mode).toBe("user_confirmed");
}); });
+22
View File
@@ -3,6 +3,7 @@ import {
displayStageLabel, displayStageLabel,
displayWorkerLabel, displayWorkerLabel,
formatWorkerDisplayTitle, formatWorkerDisplayTitle,
isFlowPlanReview,
reviewComposerCopy, reviewComposerCopy,
} from "../src/server/display-labels.js"; } from "../src/server/display-labels.js";
@@ -52,4 +53,25 @@ describe("display-labels", () => {
expect(setup.acceptLabel).toBe("选定此开场"); expect(setup.acceptLabel).toBe("选定此开场");
expect(setup.kind).toBe("opening"); 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, isOpeningSealArtifact,
parseOpeningSealPayload, parseOpeningSealPayload,
openingVariablesToTableDoc, openingVariablesToTableDoc,
reopenCreationFlowRaw,
} from "../src/skills/opening-seal.js"; } from "../src/skills/opening-seal.js";
const fragment = JSON.stringify({ const fragment = JSON.stringify({
@@ -71,4 +72,14 @@ describe("opening-seal", () => {
const closed = JSON.parse(closeCreationFlowRaw(raw)!); const closed = JSON.parse(closeCreationFlowRaw(raw)!);
expect(closed.status).toBe("closed"); 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, 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 { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
import { createSession } from "../src/runtime/phase-machine.js"; import { createSession } from "../src/runtime/phase-machine.js";
import { Blackboard } from "../src/blackboard/blackboard.js"; import { Blackboard } from "../src/blackboard/blackboard.js";
import { parseCreationFlow } from "../src/skills/creation-flow.js";
describe("phase runtime", () => { describe("phase runtime", () => {
it("auto-loads default orchestrator and awaits first user input", async () => { it("auto-loads default orchestrator and awaits first user input", async () => {
@@ -129,4 +130,167 @@ describe("phase runtime", () => {
await runtime.start(); await runtime.start();
await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行"); 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); 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, createMockToolCall,
MockLlmProvider, MockLlmProvider,
} from "../src/llm/client.js"; } from "../src/llm/client.js";
import { isUnusableMainAgentOutput } from "../src/main-agent/main-agent.js";
import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js"; import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js";
import { MAIN_AGENT_TOOL_DEFINITIONS } from "../src/main-agent/tools.js"; import { MAIN_AGENT_TOOL_DEFINITIONS } from "../src/main-agent/tools.js";
import { toolCallToDecision } from "../src/runtime/tool-registry.js"; import { toolCallToDecision } from "../src/runtime/tool-registry.js";
@@ -29,6 +30,15 @@ describe("main agent tool loop", () => {
).toEqual(["read_blackboard", "run_worker"]); ).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 () => { it("runs loop tools then returns terminal decision", async () => {
const llm = new MockLlmProvider([ const llm = new MockLlmProvider([
createMockToolCall("read_blackboard", { tags: ["book.brief"] }), createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
+120
View File
@@ -9,6 +9,8 @@ import {
parseWorkerResponseForTest, parseWorkerResponseForTest,
sanitizeWorkerSetOutputs, sanitizeWorkerSetOutputs,
} from "../src/worker/executor.js"; } 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", () => { describe("worker-set prose rejection", () => {
it("does not YAML-crash on Chinese prose; returns clear parseError", () => { it("does not YAML-crash on Chinese prose; returns clear parseError", () => {
@@ -155,6 +157,39 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.askUser?.[0]?.prompt).not.toContain("为贴近你要的质感"); 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", () => { it("recovers a root-level context-fragment into the artifact tag", () => {
const frag = { const frag = {
schema: "context-fragment.v1", schema: "context-fragment.v1",
@@ -173,6 +208,46 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(result.askUser).toBeUndefined(); 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", () => { it("moves a fragment written to 创作.当前步骤 onto the artifact tag", () => {
const frag = { const frag = {
schema: "context-fragment.v1", schema: "context-fragment.v1",
@@ -257,6 +332,51 @@ describe("sanitizeWorkerSetOutputs", () => {
expect(doc.play_slots.gm).toBe(true); 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", () => { it("keeps a whole-response truncated JSON as the artifact draft", () => {
const raw = const raw =
'{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":['; '{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":[';
+799 -175
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 { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js"; import { renderIntakePanel } from "./intake-ui.js";
import { displaySkillPackLabel, reviewComposerCopy } from "./display-labels.js"; import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
import { import {
clearQuestionCardState, clearQuestionCardState,
collectQuestionAnswers, collectQuestionAnswers,
@@ -17,9 +17,11 @@ let books = [];
let composerForceInput = false; let composerForceInput = false;
let pendingComposerDraft = ""; let pendingComposerDraft = "";
let pendingComposerDraftSeq = 0; let pendingComposerDraftSeq = 0;
/** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */
let skipComposerStash = false;
let sidebarNav = { level: "root" };
let bookSelectMode = false; let bookSelectMode = false;
let selectedBookIds = new Set(); let selectedBookIds = new Set();
const expandedBookIds = new Set();
let activePlaySaveId = null; let activePlaySaveId = null;
const playSavesByBook = new Map(); const playSavesByBook = new Map();
const playSavesLoading = new Set(); 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") { if (wr?.kind === "review_artifact") {
const copy = reviewCopyFromView(view); const copy = reviewCopyFromView(view);
return { return {
@@ -388,6 +399,22 @@ function resolveComposer(view, loading) {
const send = view.actions?.find((a) => a.type === "send_message"); const send = view.actions?.find((a) => a.type === "send_message");
const hasQuestionsCard = Boolean(getActiveQuestions(view)?.questions?.length); 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") { if (view.waitingReason?.kind === "review_artifact") {
const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError); const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError);
const copy = reviewCopyFromView(view); const copy = reviewCopyFromView(view);
@@ -432,6 +459,7 @@ function resolveComposer(view, loading) {
view.waitingReason?.kind === "worker_questions" || view.waitingReason?.kind === "worker_questions" ||
view.waitingReason?.kind === "revision" || view.waitingReason?.kind === "revision" ||
view.waitingReason?.kind === "next_intent" || view.waitingReason?.kind === "next_intent" ||
view.waitingReason?.kind === "pick_creation_step" ||
composerForceInput composerForceInput
) { ) {
const wr = view.waitingReason; const wr = view.waitingReason;
@@ -442,7 +470,7 @@ function resolveComposer(view, loading) {
submitLabel = "继续"; submitLabel = "继续";
} else if (wr?.kind === "approve_step" && composerForceInput) { } else if (wr?.kind === "approve_step" && composerForceInput) {
placeholder = placeholder =
"例如:改成丧尸怪物规则(后台 id 也换成 zombies)…"; "例如:补开局 NPC、改成丧尸怪物规则(后台 id 也换成 zombies)…";
submitLabel = "发送意见"; submitLabel = "发送意见";
} else if (wr?.kind === "revision") { } else if (wr?.kind === "revision") {
placeholder = "说明要改哪里…"; placeholder = "说明要改哪里…";
@@ -507,7 +535,38 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
</div>`; </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) { function renderComposer(view, loading) {
stashComposerDraftFromDom();
if (pendingComposerDraft) composerForceInput = true; if (pendingComposerDraft) composerForceInput = true;
const host = $("composer"); const host = $("composer");
const root = $("composer-main") || host; const root = $("composer-main") || host;
@@ -517,6 +576,13 @@ function renderComposer(view, loading) {
host._enterHandler = null; host._enterHandler = null;
} }
const spec = resolveComposer(view, loading); 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") { if (spec.mode === "waiting") {
root.innerHTML = `<div class="composer-waiting"> root.innerHTML = `<div class="composer-waiting">
@@ -556,7 +622,7 @@ function renderComposer(view, loading) {
if (spec.mode === "action") { if (spec.mode === "action") {
const reject = spec.showReject 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>` <button type="button" class="btn" data-act="force-input">说明意见</button>`
: ""; : "";
const chip = composerModeChip(spec); const chip = composerModeChip(spec);
@@ -611,6 +677,9 @@ function renderComposer(view, loading) {
: "具体写什么在步骤内确定。" : "具体写什么在步骤内确定。"
}</p>` }</p>`
: ""; : "";
const closerHint = p.closer
? `<p class="propose-plan-hint">这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。</p>`
: "";
root.innerHTML = ` root.innerHTML = `
<div class="propose-step" id="propose-step" data-step-id="${esc(p.stepId)}"> <div class="propose-step" id="propose-step" data-step-id="${esc(p.stepId)}">
<div class="propose-step-head"> <div class="propose-step-head">
@@ -619,11 +688,12 @@ function renderComposer(view, loading) {
</div> </div>
<p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p> <p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p>
${planHint} ${planHint}
${closerHint}
${fields ? `<div class="propose-fields">${fields}</div>` : ""} ${fields ? `<div class="propose-fields">${fields}</div>` : ""}
${syncHint} ${syncHint}
<div class="composer-actions"> <div class="composer-actions">
<button type="button" class="btn btn-primary" data-act="approve">${esc(spec.primary.label)}</button> <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> <button type="button" class="btn" data-act="force-input">改意见</button>
</div> </div>
</div>`; </div>`;
@@ -687,6 +757,17 @@ function renderComposer(view, loading) {
return; 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 acceptOnEmpty = Boolean(spec.acceptAction);
const acceptTone = spec.acceptAction?.tone || "accept"; const acceptTone = spec.acceptAction?.tone || "accept";
const acceptBtn = spec.acceptAction const acceptBtn = spec.acceptAction
@@ -705,7 +786,7 @@ function renderComposer(view, loading) {
: spec.placeholder; : spec.placeholder;
const shell = composerInputShell(spec, { const shell = composerInputShell(spec, {
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`, 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" : "" sendIsPrimary ? " btn-primary" : ""
}" data-act="revise" title="${esc( }" data-act="revise" title="${esc(
acceptOnEmpty acceptOnEmpty
@@ -716,6 +797,7 @@ function renderComposer(view, loading) {
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`; root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
wireComposerForm({ acceptOnEmpty }); wireComposerForm({ acceptOnEmpty });
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept")); root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
root.querySelector("[data-act=leave_step]")?.addEventListener("click", () => runAction("leave_step"));
applyPendingComposerDraft(); applyPendingComposerDraft();
} }
@@ -752,6 +834,7 @@ function applyPendingComposerDraft() {
pendingComposerDraft = ""; pendingComposerDraft = "";
pendingComposerDraftSeq = 0; pendingComposerDraftSeq = 0;
autosizeComposerInput(input); autosizeComposerInput(input);
syncPickReplanButton(input);
input.focus(); input.focus();
refreshReviewComposerChrome(); refreshReviewComposerChrome();
} }
@@ -804,10 +887,21 @@ function syncDualComposerPrimary(input, acceptBtn, sendBtn) {
sendBtn.classList.toggle("btn-primary", revisePrimary); 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 = {}) { function wireComposerForm(opts = {}) {
const form = $("composer-form"); const form = $("composer-form");
const input = $("composer-input"); const input = $("composer-input");
const acceptOnEmpty = opts.acceptOnEmpty === true; const acceptOnEmpty = opts.acceptOnEmpty === true;
const requiresText = opts.requiresText === true;
const sendBtn = form?.querySelector("[data-act=revise]"); const sendBtn = form?.querySelector("[data-act=revise]");
const root = $("composer"); const root = $("composer");
if (root?._enterHandler) { if (root?._enterHandler) {
@@ -816,12 +910,12 @@ function wireComposerForm(opts = {}) {
} }
form?.addEventListener("submit", async (e) => { form?.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
await submitComposer(input?.value ?? "", { acceptOnEmpty }); await submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
}); });
input?.addEventListener("keydown", (e) => { input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault(); e.preventDefault();
void submitComposer(input?.value ?? "", { acceptOnEmpty }); void submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
} }
}); });
sendBtn?.addEventListener("click", () => { sendBtn?.addEventListener("click", () => {
@@ -846,6 +940,7 @@ function wireComposerForm(opts = {}) {
const grow = () => { const grow = () => {
autosizeComposerInput(input); autosizeComposerInput(input);
if (acceptOnEmpty) refreshReviewComposerChrome(); if (acceptOnEmpty) refreshReviewComposerChrome();
if (requiresText) syncPickReplanButton(input);
}; };
input?.addEventListener("input", grow); input?.addEventListener("input", grow);
input?.addEventListener("change", grow); input?.addEventListener("change", grow);
@@ -868,12 +963,6 @@ function bookPlayReady(bookId) {
return saves.some((s) => s.kind === "instance" || s.kind === "run"); 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) { function formatSaveWhen(createdAt) {
const when = new Date(createdAt); const when = new Date(createdAt);
if (Number.isNaN(when.getTime())) return ""; if (Number.isNaN(when.getTime())) return "";
@@ -900,6 +989,7 @@ async function fetchPlaySavesForBook(bookId, force = false) {
} }
function setBookSelectMode(on, { keepSelection = false } = {}) { function setBookSelectMode(on, { keepSelection = false } = {}) {
if (on && sidebarNav.level === "book") return;
bookSelectMode = on; bookSelectMode = on;
if (!on) selectedBookIds = new Set(); if (!on) selectedBookIds = new Set();
else if (!keepSelection) selectedBookIds = new Set(); else if (!keepSelection) selectedBookIds = new Set();
@@ -962,8 +1052,50 @@ function showBookMenu(bookId, x, y) {
} }
} }
function expandBook(bookId) { function isCatalogNav() {
if (bookId) expandedBookIds.add(bookId); 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({ function renderExplorerRow({
@@ -1025,7 +1157,7 @@ function renderExplorerRow({
return row; return row;
} }
function renderBookBranch(book, list) { function renderBookBranch(book, list, { nested = false } = {}) {
const loading = playSavesLoading.has(book.id); const loading = playSavesLoading.has(book.id);
const saves = playSavesByBook.get(book.id); const saves = playSavesByBook.get(book.id);
const ready = bookPlayReady(book.id); const ready = bookPlayReady(book.id);
@@ -1045,7 +1177,7 @@ function renderBookBranch(book, list) {
name: "当前", name: "当前",
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
active: playHere && !activePlaySaveId, active: playHere && !activePlaySaveId,
child: true, child: nested,
onClick: () => void openCurrentPlay(book.id), onClick: () => void openCurrentPlay(book.id),
}), }),
); );
@@ -1058,7 +1190,7 @@ function renderBookBranch(book, list) {
name: s.label, name: s.label,
meta: when ? `游玩 · ${when}` : "游玩进度", meta: when ? `游玩 · ${when}` : "游玩进度",
active: playHere && activePlaySaveId === s.id, active: playHere && activePlaySaveId === s.id,
child: true, child: nested,
actionClass: "explorer-file", actionClass: "explorer-file",
onClick: () => void loadPlaySave(book.id, s.id), onClick: () => void loadPlaySave(book.id, s.id),
onDelete: () => void deletePlaySave(book.id, s.id, s.label), onDelete: () => void deletePlaySave(book.id, s.id, s.label),
@@ -1071,16 +1203,30 @@ function renderBookBranch(book, list) {
renderExplorerRow({ renderExplorerRow({
name: "+ 新开一局", name: "+ 新开一局",
actionClass: "explorer-action", actionClass: "explorer-action",
child: true, child: nested,
onClick: () => void startNewPlayForBook(book.id), 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() { function renderBookList() {
const list = $("book-list"); const list = $("book-list");
if (!list) return; if (!list) return;
syncRailContext();
if (!books.length) { if (!books.length) {
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`; 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); $("btn-new-inline")?.addEventListener("click", openNewBookDialog);
@@ -1088,39 +1234,36 @@ function renderBookList() {
} }
list.innerHTML = ""; 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) { for (const book of books) {
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计"; const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
const selected = selectedBookIds.has(book.id); 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( list.appendChild(
renderExplorerRow({ renderExplorerRow({
name: book.title, name: book.title,
meta: skill, meta: skill,
active: bookActive,
selected, selected,
bookId: book.id, bookId: book.id,
actionClass: "explorer-folder", 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: () => { onClick: () => {
if (bookSelectMode) { if (bookSelectMode) {
toggleBookSelection(book.id); toggleBookSelection(book.id);
return; 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)); books = books.filter((b) => !deleted.has(b.id));
setBookSelectMode(false); setBookSelectMode(false);
if (activeBookId && deleted.has(activeBookId)) { if (activeBookId && deleted.has(activeBookId)) {
if (books.length) await openBook(books[0].id); if (books.length) showCatalog();
else renderEmpty(); else renderEmpty();
} else { } else {
renderBookList(); renderBookList();
@@ -1229,9 +1372,9 @@ function setupBookBoxSelect() {
}; };
list.addEventListener("mousedown", (e) => { 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("#book-action-menu")) return;
if (e.target.closest(".explorer-row.child")) return; if (e.target.closest(".explorer-row")) return;
dragging = true; dragging = true;
start = { x: e.clientX, y: e.clientY }; start = { x: e.clientX, y: e.clientY };
overlay.hidden = true; overlay.hidden = true;
@@ -1282,8 +1425,8 @@ function setupBookBoxSelect() {
async function loadPlaySave(bookId, saveId) { async function loadPlaySave(bookId, saveId) {
try { try {
expandBook(bookId); navigateToBook(bookId);
if (lastView) renderSession(lastView, true); if (lastView?.bookId === bookId) renderSession(lastView, true);
const data = await api( const data = await api(
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`, `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`,
{ method: "POST" }, { method: "POST" },
@@ -1316,8 +1459,8 @@ async function startNewPlayForBook(bookId) {
return; return;
} }
try { try {
expandBook(bookId); navigateToBook(bookId);
if (lastView) renderSession(lastView, true); if (lastView?.bookId === bookId) renderSession(lastView, true);
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" }); const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" });
activePlaySaveId = null; activePlaySaveId = null;
renderSession(data.session, false); renderSession(data.session, false);
@@ -1328,7 +1471,7 @@ async function startNewPlayForBook(bookId) {
} }
async function openBookDesign(bookId) { async function openBookDesign(bookId) {
expandBook(bookId); navigateToBook(bookId);
activePlaySaveId = null; activePlaySaveId = null;
if (activeBookId !== bookId || !sessionId) { if (activeBookId !== bookId || !sessionId) {
await openBook(bookId); await openBook(bookId);
@@ -1341,7 +1484,7 @@ async function openBookDesign(bookId) {
} }
async function openCurrentPlay(bookId) { async function openCurrentPlay(bookId) {
expandBook(bookId); navigateToBook(bookId);
const alreadyHere = const alreadyHere =
activeBookId === bookId && activeBookId === bookId &&
isPlayNow() && isPlayNow() &&
@@ -1390,12 +1533,12 @@ function renderHeader(view, loading) {
if (btnExport) btnExport.disabled = !(view.messages?.length); if (btnExport) btnExport.disabled = !(view.messages?.length);
} }
function renderEmpty() { function closeWorkspace({ emptyCopy = "" } = {}) {
stopLivePoll();
sessionId = null; sessionId = null;
lastView = null; lastView = null;
activeBookId = null; activeBookId = null;
activePlaySaveId = null; activePlaySaveId = null;
setBookSelectMode(false);
composerForceInput = false; composerForceInput = false;
document.title = "Writing Agent"; document.title = "Writing Agent";
$("status-dot").className = "status-dot"; $("status-dot").className = "status-dot";
@@ -1406,7 +1549,7 @@ function renderEmpty() {
if (btnInst) btnInst.hidden = true; if (btnInst) btnInst.hidden = true;
const btnExport = $("btn-export"); const btnExport = $("btn-export");
if (btnExport) btnExport.disabled = true; 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"); const stage = $("workspace-stage");
if (stage) { if (stage) {
stage.hidden = true; stage.hidden = true;
@@ -1439,6 +1582,14 @@ function renderEmpty() {
document.body.dataset.lifecycle = "design"; document.body.dataset.lifecycle = "design";
const composerMain = $("composer-main"); const composerMain = $("composer-main");
if (composerMain) composerMain.innerHTML = `<div class="composer-idle">暂无打开的作品</div>`; 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(); renderBookList();
} }
@@ -1490,12 +1641,14 @@ function startLivePoll() {
} }
function renderSession(view, loading = false) { function renderSession(view, loading = false) {
if (isCatalogNav()) return;
lastView = view; lastView = view;
sessionId = view.id; sessionId = view.id;
activeBookId = view.bookId ?? activeBookId; activeBookId = view.bookId ?? activeBookId;
if ( if (
!loading && !loading &&
view.waitingReason?.kind !== "approve_step" && view.waitingReason?.kind !== "approve_step" &&
view.waitingReason?.kind !== "pick_creation_step" &&
view.waitingReason?.kind !== "review_artifact" && view.waitingReason?.kind !== "review_artifact" &&
view.waitingReason?.kind !== "worker_questions" && view.waitingReason?.kind !== "worker_questions" &&
!(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length) !(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length)
@@ -1524,6 +1677,12 @@ function renderSession(view, loading = false) {
onSwitchVariant: (messageId, direction) => onSwitchVariant: (messageId, direction) =>
messageAction("variant", messageId, { direction }), messageAction("variant", messageId, { direction }),
onRetryRun: () => retryRun(), 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); renderComposer(view, loading);
if (loading) startLivePoll(); if (loading) startLivePoll();
@@ -1573,8 +1732,12 @@ function selectedQuestionAnswers(view) {
} }
/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */ /** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */
async function submitComposer(text, { acceptOnEmpty } = {}) { async function submitComposer(text, { acceptOnEmpty, requiresText } = {}) {
const trimmed = String(text ?? "").trim(); const trimmed = String(text ?? "").trim();
if (requiresText && !trimmed) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if (acceptOnEmpty && !trimmed) { if (acceptOnEmpty && !trimmed) {
const { answered } = selectedQuestionAnswers(lastView); const { answered } = selectedQuestionAnswers(lastView);
if (answered.length === 0) { if (answered.length === 0) {
@@ -1609,7 +1772,11 @@ async function sendText(text) {
if (reviewing) { if (reviewing) {
const { collected, answered } = selectedQuestionAnswers(lastView); const { collected, answered } = selectedQuestionAnswers(lastView);
if (!trimmed && answered.length === 0) { if (!trimmed && answered.length === 0) {
alert("请选择追问选项或填写修改意见;满意请点「接受」收下产物。"); alert(
isFlowPlanReview(lastView)
? "点图上节点即确认并进入;要改编排请先写下意见。"
: "请选择追问选项或填写修改意见;满意请点「接受」收下产物。",
);
return; return;
} }
try { try {
@@ -1681,6 +1848,13 @@ async function sendText(text) {
} }
if (!trimmed) { if (!trimmed) {
if (
lastView?.waitingReason?.kind === "pick_creation_step" ||
isFlowPlanReview(lastView)
) {
alert("点图上节点即确认并进入;要改编排请先写下意见。");
return;
}
if ( if (
lastView?.waitingReason?.kind === "next_intent" || lastView?.waitingReason?.kind === "next_intent" ||
resolveComposer(lastView, false)?.allowEmpty resolveComposer(lastView, false)?.allowEmpty
@@ -1789,7 +1963,6 @@ async function loadBooks() {
const data = await api("/api/books"); const data = await api("/api/books");
books = data.books ?? []; books = data.books ?? [];
renderBookList(); renderBookList();
for (const book of books) void fetchPlaySavesForBook(book.id);
} }
function openNewBookDialog() { function openNewBookDialog() {
@@ -1865,7 +2038,7 @@ async function createBook() {
$("dialog-new-book").close(); $("dialog-new-book").close();
if (data.book) books.unshift(data.book); if (data.book) books.unshift(data.book);
activeBookId = data.book?.id; activeBookId = data.book?.id;
expandBook(data.book.id); navigateToBook(data.book.id);
renderSession(data.session, false); renderSession(data.session, false);
} catch (err) { } catch (err) {
alert(err.message); alert(err.message);
@@ -1875,19 +2048,22 @@ async function createBook() {
} }
async function openBook(bookId) { async function openBook(bookId) {
const seq = beginUiRequest();
activeBookId = bookId; activeBookId = bookId;
expandBook(bookId); navigateToBook(bookId);
renderBookList(); if (lastView?.bookId === bookId) renderSession(lastView, true);
if (lastView) renderSession(lastView, true);
try { try {
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" }); const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" });
if (isStaleUiRequest(seq)) return;
if (data.book) { if (data.book) {
const i = books.findIndex((b) => b.id === bookId); const i = books.findIndex((b) => b.id === bookId);
if (i >= 0) books[i] = { ...books[i], ...data.book }; if (i >= 0) books[i] = { ...books[i], ...data.book };
} }
renderSession(data.session, false); renderSession(data.session, false);
} catch (err) { } 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" }); await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" });
books = books.filter((b) => b.id !== bookId); books = books.filter((b) => b.id !== bookId);
playSavesByBook.delete(bookId); playSavesByBook.delete(bookId);
expandedBookIds.delete(bookId);
selectedBookIds.delete(bookId); selectedBookIds.delete(bookId);
if (activeBookId === bookId) activePlaySaveId = null; if (activeBookId === bookId) activePlaySaveId = null;
hideBookMenu(); hideBookMenu();
if (activeBookId === bookId) { const wasHere = activeBookId === bookId || sidebarNav.bookId === bookId;
if (books.length) { if (wasHere) {
await openBook(books[0].id); if (books.length) showCatalog();
} else { else renderEmpty();
renderEmpty();
}
} else { } else {
renderBookList(); 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) { function showContextTraceDialog(messageId) {
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId); const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
const review = lastView?.reviewArtifact; const review = lastView?.reviewArtifact;
@@ -2102,7 +2290,13 @@ $("btn-save-instance")?.addEventListener("click", () => {
window.addEventListener("wa:session-updated", (e) => { window.addEventListener("wa:session-updated", (e) => {
const view = e.detail; 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) => { document.addEventListener("click", (e) => {
@@ -2116,7 +2310,7 @@ async function init() {
void populateDirectorSelect(); void populateDirectorSelect();
await loadBooks(); await loadBooks();
if (books.length) { if (books.length) {
await openBook(books[0].id); showCatalog();
} else { } else {
renderEmpty(); renderEmpty();
openNewBookDialog(); openNewBookDialog();
+10 -2
View File
@@ -94,6 +94,14 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
return base; return base;
} }
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
export function isFlowPlanReview(view) {
return (
view?.waitingReason?.kind === "review_artifact" &&
view?.reviewArtifact?.workerId === "design-flow"
);
}
/** /**
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。 * `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。 * 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
@@ -131,8 +139,8 @@ export function reviewComposerCopy(workerId, opts = {}) {
taskLabel: "开场", taskLabel: "开场",
taskTitle: "选定开场白", taskTitle: "选定开场白",
hint: hasQuestions hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。" ? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。", : "同意就选定此开场;要改开场白就写意见。",
submitLabel: "按意见修改", submitLabel: "按意见修改",
acceptLabel: "选定此开场", acceptLabel: "选定此开场",
placeholder: "修改意见…", placeholder: "修改意见…",
+28 -12
View File
@@ -12,25 +12,28 @@
/> />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
<body data-lifecycle="design"> <body data-lifecycle="design" data-nav-level="root">
<div class="layout"> <div class="layout">
<aside class="panel panel-rail" id="panel-books"> <aside class="panel panel-rail" id="panel-books">
<header class="panel-head rail-head"> <header class="panel-head rail-head">
<button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="展开 / 收起侧栏" aria-label="展开或收起侧栏">‹</button> <div class="rail-head-row">
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="侧栏"> <button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="返回作品目录" aria-label="返回作品目录">‹</button>
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="作品"> <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-icon" aria-hidden="true">书</span>
<span class="rail-tab-label">作品</span> <span class="rail-tab-label">本书</span>
</button> </button>
<button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度"> <button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度">
<span class="rail-tab-icon" aria-hidden="true">志</span> <span class="rail-tab-icon" aria-hidden="true">志</span>
<span class="rail-tab-label">调度</span> <span class="rail-tab-label">调度</span>
</button> </button>
</nav> </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> </header>
<div class="rail-panels"> <div class="rail-panels">
@@ -50,9 +53,7 @@
<div class="main-body"> <div class="main-body">
<section class="panel-feed" id="panel-feed"> <section class="panel-feed" id="panel-feed">
<div id="skill-picker" class="skill-picker" hidden></div> <div id="skill-picker" class="skill-picker" hidden></div>
<div id="message-feed" class="message-feed"> <div id="message-feed" class="message-feed"></div>
<p class="empty">正在打开作品…</p>
</div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div> <div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div> <div id="questions-card-host" class="questions-card-host" hidden></div>
</section> </section>
@@ -138,6 +139,21 @@
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button> <button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
</menu> </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"> <dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form"> <form method="dialog" class="context-trace-form">
<h2 id="context-trace-title">上下文调用日志</h2> <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); 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 —— */ /* —— Toast —— */
.settings-toast { .settings-toast {
margin: 10px 28px 0; margin: 10px 28px 0;
@@ -1042,7 +1073,8 @@ code {
font-weight: 500; font-weight: 500;
} }
.profile-dialog input { .profile-dialog input,
.profile-dialog select {
padding: 9px 12px; padding: 9px 12px;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--st-border-strong); border: 1px solid var(--st-border-strong);
@@ -1051,12 +1083,25 @@ code {
font-size: 14px; font-size: 14px;
} }
.profile-dialog input:focus { .profile-dialog select {
cursor: pointer;
}
.profile-dialog input:focus,
.profile-dialog select:focus {
outline: none; outline: none;
border-color: #93c5fd; border-color: #93c5fd;
box-shadow: 0 0 0 3px var(--st-accent-soft); 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 { .dialog-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+11
View File
@@ -148,6 +148,17 @@
Model Model
<input name="model" required placeholder="deepseek-chat" /> <input name="model" required placeholder="deepseek-chat" />
</label> </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"> <div class="dialog-actions">
<button type="button" id="profile-cancel" class="btn-secondary">取消</button> <button type="button" id="profile-cancel" class="btn-secondary">取消</button>
<button type="button" id="profile-save" 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>`; 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() { function renderProfiles() {
profilesListEl.innerHTML = ""; profilesListEl.innerHTML = "";
if (!state.profiles.length) { if (!state.profiles.length) {
@@ -171,13 +196,18 @@ function renderProfiles() {
${active ? '<span class="badge">当前</span>' : ""} ${active ? '<span class="badge">当前</span>' : ""}
</div> </div>
<div class="config-card-meta">${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}</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> </div>
<div class="config-card-actions"> <div class="config-card-actions">
${activateButtonHtml(active, p.id, "activate-profile")} ${activateButtonHtml(active, p.id, "activate-profile")}
<button type="button" data-action="edit-profile" data-id="${p.id}">编辑</button> <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> <button type="button" class="btn-danger" data-action="delete-profile" data-id="${p.id}">删除</button>
</div> </div>
<div class="test-result" id="test-${p.id}"></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.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com";
profileForm.apiKey.value = profile?.apiKey ?? ""; profileForm.apiKey.value = profile?.apiKey ?? "";
profileForm.model.value = profile?.model ?? "deepseek-v4-pro"; profileForm.model.value = profile?.model ?? "deepseek-v4-pro";
profileForm.reasoningEffort.value = profile?.reasoningEffort ?? "";
profileDialog.showModal(); profileDialog.showModal();
} }
@@ -375,6 +406,7 @@ async function saveProfile(activate) {
name: profileForm.name.value, name: profileForm.name.value,
baseUrl: profileForm.baseUrl.value, baseUrl: profileForm.baseUrl.value,
model: profileForm.model.value, model: profileForm.model.value,
reasoningEffort: profileForm.reasoningEffort.value,
activate, activate,
}; };
const apiKey = profileForm.apiKey.value.trim(); const apiKey = profileForm.apiKey.value.trim();
@@ -457,13 +489,27 @@ profilesListEl.addEventListener("click", async (e) => {
await activateProfile(id); await activateProfile(id);
} else if (action === "edit-profile") { } else if (action === "edit-profile") {
openProfileDialog(state.profiles.find((p) => p.id === id)); openProfileDialog(state.profiles.find((p) => p.id === id));
} else if (action === "test-profile") { } else if (action === "probe-capabilities") {
const el = document.getElementById(`test-${id}`); const el = document.getElementById(`test-${id}`);
el.textContent = "测试中…"; el.textContent = "探测中(含连通性,会打几次短请求)…";
el.className = "test-result"; el.className = "test-result";
const result = await api(`/api/profiles/${id}/test`, { method: "POST" }); const result = await api(`/api/profiles/${id}/probe-capabilities`, {
el.textContent = result.message; method: "POST",
el.className = `test-result ${result.ok ? "ok" : "fail"}`; });
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") { } else if (action === "delete-profile") {
if (!confirm("确定删除此 API 配置?")) return; if (!confirm("确定删除此 API 配置?")) return;
await api(`/api/profiles/${id}`, { method: "DELETE" }); await api(`/api/profiles/${id}`, { method: "DELETE" });
+736 -29
View File
@@ -69,12 +69,35 @@ a.link:hover { text-decoration: underline; }
flex-shrink: 0; flex-shrink: 0;
} }
.rail-head { .rail-head {
gap: 4px; flex-direction: column;
padding: 6px; align-items: stretch;
flex-wrap: nowrap; justify-content: flex-start;
align-items: center; gap: 0;
padding: 0;
min-width: 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 { .rail-toggle {
flex-shrink: 0; flex-shrink: 0;
width: 28px; width: 28px;
@@ -87,12 +110,19 @@ a.link:hover { text-decoration: underline; }
line-height: 1; line-height: 1;
} }
body.rail-collapsed .rail-toggle { transform: rotate(180deg); } 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 { .rail-tabs {
display: flex; display: none;
flex: 1 1 auto; gap: 0;
gap: 2px;
min-width: 0; min-width: 0;
padding: 0 8px;
} }
body[data-nav-level="book"] .rail-tabs {
display: flex;
}
.rail-tabs[hidden] { display: none !important; }
.rail-tab { .rail-tab {
flex: 1 1 0; flex: 1 1 0;
display: inline-flex; display: inline-flex;
@@ -102,21 +132,28 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
min-width: 0; min-width: 0;
height: 28px; height: 28px;
border: none; border: none;
border-bottom: 2px solid transparent;
background: transparent; background: transparent;
border-radius: 6px; border-radius: 0;
padding: 0 6px; padding: 0 6px;
cursor: pointer; cursor: pointer;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 500;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.rail-tab:hover { background: var(--bg); color: var(--text); } .rail-tab:hover { color: var(--text); }
.rail-tab.active { .rail-tab.active {
background: var(--accent-soft); background: transparent;
color: var(--accent); 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 { .rail-tab-icon {
display: none; display: none;
@@ -177,8 +214,8 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
.rail-tab-panel[hidden] { display: none !important; } .rail-tab-panel[hidden] { display: none !important; }
body.rail-collapsed .rail-tab-label, body.rail-collapsed .rail-tab-label,
body.rail-collapsed .path-bar, body.rail-collapsed .rail-context,
body.rail-collapsed .rail-head-actions #btn-new-book, body.rail-collapsed .rail-head-actions,
body.rail-collapsed .burst-badge { display: none !important; } body.rail-collapsed .burst-badge { display: none !important; }
body.rail-collapsed .rail-tab-icon { display: block; } body.rail-collapsed .rail-tab-icon { display: block; }
body.rail-collapsed .rail-head { body.rail-collapsed .rail-head {
@@ -187,14 +224,27 @@ body.rail-collapsed .rail-head {
padding: 6px 4px; padding: 6px 4px;
gap: 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; flex-direction: column;
padding: 0;
} }
body.rail-collapsed .rail-tab { body.rail-collapsed .rail-tab {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%; width: 100%;
height: 32px; height: 32px;
padding: 0; 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 .rail-panels { display: none; }
body.rail-collapsed .panel-rail { align-items: stretch; } body.rail-collapsed .panel-rail { align-items: stretch; }
@@ -734,6 +784,39 @@ body.design-workspace .review-worker-set {
color: var(--accent); color: var(--accent);
margin-bottom: 8px; 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-intent,
.workspace-answer { .workspace-answer {
flex: 1 1 auto; flex: 1 1 auto;
@@ -884,6 +967,406 @@ body.design-workspace .flow-status {
font-size: 12px; font-size: 12px;
color: var(--muted); 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 { body.design-workspace .flow-steps {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -1004,8 +1487,20 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
cursor: pointer; cursor: pointer;
padding: 6px 10px; 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-sm { padding: 4px 8px; font-size: 12px; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); } .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-danger { color: var(--danger); border-color: #fecaca; }
.btn:disabled, .btn-sm:disabled { opacity: 0.5; cursor: not-allowed; } .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)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
} }
/* 美学验收:左契约 / 右诊断(整体样式约定) */ /* 美学验收:左契约 / 右诊断 */
.af-split { .af-split {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2002,13 +2497,13 @@ body.design-workspace .af-mosaic {
background: var(--bg); background: var(--bg);
} }
.af-block-head { .af-block-head {
margin-bottom: 10px; margin-bottom: 8px;
} }
.af-block-title { .af-block-title {
margin: 0 0 4px; margin: 0;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 15px; font-size: 14px;
font-weight: 700; font-weight: 650;
color: var(--text); color: var(--text);
letter-spacing: 0.01em; letter-spacing: 0.01em;
display: flex; display: flex;
@@ -2032,20 +2527,80 @@ body.design-workspace .af-mosaic {
.af-block-body { .af-block-body {
min-width: 0; 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); .af-split--aesthetics .af-split-product {
font-size: 15.5px; gap: 22px;
line-height: 1.75;
max-width: 42rem;
} }
.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; 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-size: 12px;
font-weight: 650;
color: var(--muted); 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) { @media (max-width: 900px) {
.af-split-grid { .af-split-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -2368,6 +2923,145 @@ body.design-workspace .af-mosaic {
flex-direction: column; flex-direction: column;
gap: 14px; 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 { .skill-card-section > h4 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 13px; font-size: 13px;
@@ -2395,6 +3089,14 @@ body.design-workspace .af-mosaic {
font-size: 13px; font-size: 13px;
line-height: 1.45; 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) { @media (max-width: 640px) {
.skill-kv-row { grid-template-columns: 1fr; gap: 2px; } .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[hidden] { display: none; }
.context-menu-item:hover { background: var(--bg); } .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-separator-before { border-top: 1px solid var(--border); }
.context-menu-danger { color: var(--danger); } .context-menu-danger { color: var(--danger); }
.context-menu-danger:hover { background: #fef2f2; } .context-menu-danger:hover { background: #fef2f2; }
@@ -3976,7 +4683,7 @@ body.design-workspace .af-mosaic {
.layout { grid-template-columns: var(--books-w-collapsed) 1fr; } .layout { grid-template-columns: var(--books-w-collapsed) 1fr; }
body:not(.rail-collapsed) { --books-w: var(--books-w-collapsed); } body:not(.rail-collapsed) { --books-w: var(--books-w-collapsed); }
body:not(.rail-collapsed) .rail-tab-label, 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; } body:not(.rail-collapsed) .rail-panels { display: none; }
} }