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:
@@ -4,7 +4,7 @@
|
||||
|
||||
| 产物/技能 | 美化程度 | 渲染入口 |
|
||||
|-----------|----------|----------|
|
||||
| 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式)/ 右诊断(设定逻辑) | `renderSpecialtyBodyHtml` → aesthetics |
|
||||
| 美学纲领与交互范式 | **左右分栏**(左契约可读 / 右诊断密);无工程 banner | `af-split--aesthetics` + `renderAestheticsNodeHtml` |
|
||||
| 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` |
|
||||
| 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` |
|
||||
| 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` |
|
||||
|
||||
@@ -121,8 +121,7 @@
|
||||
"steps": [
|
||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "id": "生成规则", "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] },
|
||||
{ "id": "美学纲领与交互范式·改", "name": "美学纲领与交互范式", "mode": "revise", "revises": "美学纲领与交互范式", "depends_on": ["生成规则#2"] }
|
||||
{ "id": "生成规则#2", "name": "生成规则", "depends_on": ["生成规则"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -134,12 +133,10 @@
|
||||
| `id` | 本局步骤唯一键;同技能多次必须不同 |
|
||||
| `name` | 必须与技能目录中的**固定中文名**完全一致(可重复) |
|
||||
| `depends_on` | 依赖的其它步骤 **id**(若某 name 在本流程唯一,也可写 name) |
|
||||
| `mode` | 可选。缺省 / `fresh` = 从零新建;`revise` = 回头修改,继承 `revises` 所指那步的既有产物 |
|
||||
| `revises` | `mode=revise` 时必填:被改的既有步骤 id(须更前、同 name) |
|
||||
| `params` | 可选。普通节点=编排参数。〔先验产物〕=编排器规划的产物内容(可空;执行注入【规划产物】) |
|
||||
|
||||
程序用中文 `name` 映射到 `artifact` tag;流程 JSON **不写**英文模块 id / tag。
|
||||
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改用 `mode=revise`,不要再排一条 fresh。
|
||||
**禁止**一次排死全程固定长链;「生成规则」「具体实例」等标了 `repeatable` 的技能应允许多次**新建**编入。已完成节点要改:用户在图上点该节点重进,不要排 `mode=revise` 新步。
|
||||
〔先验产物〕节点可提前规划多条「写什么」写入 params;规划可空,「生成什么」在步内钉。
|
||||
|
||||
## 2.5 已合并技能(勿拆回)
|
||||
|
||||
@@ -29,9 +29,13 @@
|
||||
推荐游玩调度:`gm → perspective? → narrator → auditor`。
|
||||
旁观维护与叙事转述默认**不注入**「对话.历史」;主世界层按投影排序表裁剪历史。
|
||||
|
||||
## 3. 上下文片段:`context-fragment.v1`
|
||||
## 3. 上下文片段:`context-fragment.v1` / 扁形 `artifact.flat.v1`
|
||||
|
||||
公共外壳(**所有上下文创作技能相同**):
|
||||
**投递**(全局):按 API profile 探测能力选 `json_schema` → `forced_tool` → `json_object`,与具体技能无关。
|
||||
|
||||
**技能稿默认 `context-fragment.v1`**:`正文` 为对象树,深度约 **2 层分组 + 叶子**(短字段、string[],或诊断 `{结论, 依据, 完备度}`)。不要把整柱收成一段散文。扁形 `artifact.flat.v1` 仅为投递兼容;若 `sections[].text` 是 JSON 对象字符串,程序会还原进正文键。
|
||||
|
||||
公共外壳:
|
||||
|
||||
| 段 | 含义 |
|
||||
|----|------|
|
||||
@@ -54,7 +58,7 @@
|
||||
**存盘**:三块都在同一 `正文` 里。**注入**:默认 `full` 时整份正文都会进模型——若日后只要左栏进游玩,再改投影,不靠藏 UI。
|
||||
实现机制正文:`依据的核心体验` + `支撑点[]` + `支撑点关系[]` + `覆盖检验`(见 `mechanism`)。
|
||||
舞台骨架正文:尺度 / 基底与变造 / **社会结构** / **世界状况** / 关键舞台区 / 未展开范围(见 `world-blueprint`)。
|
||||
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段);自评=必要性/属性妥当/格式准确。
|
||||
生成规则正文:必要性判断 + `rules[]`(生成与描写 + **单层**产物格式 + 可选多池绑顶层字段 + **数量**与**一次生成**(单条|同批,同批须钉记录间关系));自评=必要性/属性妥当/格式准确。
|
||||
美学纲领自评仍为:交互范式 / 美学纲领 / 整体协调(勿与生成规则三维混用)。
|
||||
叙事指南与故事推进正文:一份全文(纲领/遣词/笔墨/禁忌/推进等);**整份**双挂转述与主世界层,不为省 token 拆两套投影。artifact=`设计.叙事指南与故事推进`。
|
||||
正文组成:**基于固定呈现壳适配微调**(选 `shell_id` + 微调轴;块只挂壳已有区域);正文含 **`示例`**(`present.v1` 灌数,验收壳与美化;非正史,文笔不重要);artifact=`设计.正文组成`。见 `docs/play-presentation-shells.md`。
|
||||
@@ -65,7 +69,7 @@
|
||||
开场白与开场变量:开场正文(守正文组成)+ 开场初值;可经 opening-generator 落库。
|
||||
|
||||
|
||||
具体实例正文:`rule_id` + `records[]`(单层,键随规则);自评=合规模(只按规则执行)。
|
||||
具体实例正文:`rule_id` + `records[]`(始终数组;单层,键随规则;条数服从规则「一次生成」);自评=合规模(只按规则执行)。
|
||||
其它技能换掉正文内块名与自评维度名即可,三段外壳不要拆。
|
||||
## 4. 投影排序:`context-order.v1`(扁平序)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Book 跨 Session:过程、资产、游玩
|
||||
新建作品 → UI 选配方 → 用户首句
|
||||
→ design-flow:排出近期 设计.创作流程(工作流计划 / 增量 DAG,status=open|closed)
|
||||
→ 反复 design-step:注入当前技能 modules/{id}/prompt.md + 依赖产物
|
||||
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**;已完成节点用回头修改继承旧稿)→ closed
|
||||
→ 不够则再 design-flow(可追加 / 反复调用 repeatable 技能作**新建**)→ closed
|
||||
→ 收成 设计.worker集(运行规格)→ 验收后才可进 play
|
||||
→ 开场白与开场变量(若编排):1~多条开场 → 选定 → 程序落库、关 DAG、保存 instance
|
||||
→ (无开场白节点时可选)opening-generator 兜底落库
|
||||
|
||||
+6
-3
@@ -35,7 +35,7 @@
|
||||
|
||||
**一层选型**:新建作品只选**配方**,不要再叠「能力包 + 配方」两层。
|
||||
|
||||
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改用**回头修改**(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
|
||||
**调味**:配方给出的建议步骤是**近期起点**(horizon);编排时可增量追加、可反复调用标了可反复的技能(每次=彻底新建)。已完成节点要改:在图上点该节点重进(继承旧产物),不必整局重开。验收的是本局【工作流计划】与【运行规格】,不是磁盘上的静态菜谱。
|
||||
|
||||
**剧本拆分**:旧称「剧本」同时罩住流程与规格,易混。现固定拆成:
|
||||
- **工作流计划** = 怎么排技能步骤(DAG)
|
||||
@@ -181,12 +181,15 @@
|
||||
|----------------------|----------------|----------|
|
||||
| `intake` | 描述需求 | 发送 |
|
||||
| `input` | 补充说明 | 发送 / 补充并发送 |
|
||||
| `worker_questions` | 答题 | 补充并发送 |
|
||||
| `approve_step` | 确认 | 同意并开始;回头修改时文案为「回头修改 · {技能名}」 |
|
||||
| `worker_questions` | 答题 | 补充并发送;已进技能步时旁挂「返回节点」 |
|
||||
| `approve_step` | 确认 | 同意并开始;重进已完成节点时文案为「回头修改 · {技能名}」;次要为「返回节点」 |
|
||||
| `pick_creation_step` | 选节点 | 点可进入的节点即开始;要改排在底栏写意见再发 |
|
||||
| `review_artifact` | 视产物(勿一律「验收」) | **流程编排** → 确认编排;技能步 → 接受;开场 → 选定此开场。旁挂「按意见改编排 / 按意见修改」 |
|
||||
| `next_intent` | 说话 | 继续(可留空) |
|
||||
| `revision` | 说话 | 按意见修改 |
|
||||
|
||||
**返回节点**:已点进某步(尤其可增殖原型刚增殖出的实例)但还没写完时,退回分层图。未验收的空实例从图上撤掉,避免误点留下一条空槽。已完成节点重进后返回则保留该节点。勿写成「取消作品」或浏览器后退。
|
||||
|
||||
---
|
||||
|
||||
## 8. 禁止出现在用户主路径上的写法
|
||||
|
||||
@@ -127,7 +127,7 @@ declaration: …
|
||||
→ 循环直到「接受」
|
||||
→ 直接提案下一节点并确认开干(普通节点可在此钉编排参数;〔先验产物〕不在此钉「写什么」;〔程序步〕如投影排序跳过确认直接执行)
|
||||
· 可反复技能再来一次 = 彻底新建(新 id / 新 params)
|
||||
· 已完成节点要改 = 回头修改(mode=revise,继承旧产物)
|
||||
· 已完成节点要改 = 用户在图上点该节点(查看产物 / 重新进入修改),不在 DAG 里加修订步
|
||||
→ 下一节点 opening / design-step …
|
||||
直到「开场白与开场变量」(〔收口〕)
|
||||
→ 产出 1~多条开场白 → 选定 → 程序落库、关闭 DAG、保存创作定稿
|
||||
@@ -135,7 +135,7 @@ declaration: …
|
||||
|
||||
「按意见修改」只重跑当前节点,不得跳到「下一步想写什么」。底栏空且未选追问时 Enter=接受;已选追问时 Enter=按意见修改。点按钮仍只做按钮自己的事。
|
||||
|
||||
「回头修改」是编排层能力:给已完成步骤追加一条 `mode=revise` 的新步,执行时注入既有产物继续改。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,用回头修改,不必整局重开。
|
||||
「回头修改」是已完成节点上的动作,不是 DAG 节点:用户点该格,菜单里回看产物或重进继承旧稿。它**不是**〔可反复〕再来一次——后者从零新建。漏了前面的节点、或谈完才发现要改上游,点那个已完成节点即可,不必整局重开。
|
||||
|
||||
〔先验产物〕节点(生成规则、具体实例):编排器可提前规划「写什么」写入 params——提前规划多条是正确的。确认开干不补「生成什么」;执行时注入【规划产物】,步内钉或修订。
|
||||
|
||||
@@ -188,6 +188,6 @@ recipes/world-simulator|expand-assistant/recipe.yaml
|
||||
1. 只选配方 → 出**近期**创作流程(`status=open`)
|
||||
2. design-step 能切割出 `opening`/`task`/…
|
||||
3. 有 `opening` 时先程序开场再 LLM
|
||||
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);也可 `mode=revise` 回头修改已完成节点(继承旧产物);收成前 `status=closed`
|
||||
5. UI 用编排术语;有编排参数的步骤须展示 params;回头修改在流程上标「回头修改」,确认开干文案与「接下来生成」区分
|
||||
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;回头修改可继承原步 params
|
||||
4. 可追加同能力多次(不同 step.id + params,**彻底新建**);已完成节点由用户在图上点选重进(继承旧产物);收成前 `status=closed`
|
||||
5. UI 用编排术语;有编排参数的步骤须展示 params;已完成节点菜单区分「查看产物」与「重新进入修改」
|
||||
6. 缺必填 params 的**普通**步骤不得视为可执行(校验失败 / 编排先 askUser);〔先验产物〕规划可空,不拦确认开干;重进已完成节点可继承原步 params
|
||||
|
||||
@@ -25,7 +25,7 @@ worker-templates/
|
||||
```text
|
||||
选导演(recipes)→ design-flow → 验收近期 设计.创作流程(status=open)
|
||||
→ 反复 design-step(注入 modules/{id}/prompt.md)
|
||||
→ 不够则再 design-flow(可追加 repeatable 新建,或 mode=revise 回头修改)→ closed
|
||||
→ 不够则再 design-flow(可追加 repeatable 新建)→ closed
|
||||
→ (可选)opening-generator → 手动进 play
|
||||
```
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ feeds: design_only
|
||||
与用户一起钉清一份「体验契约」:给玩家什么感受、如何参与、正文如何呈现、何时等待用户。
|
||||
|
||||
产物必须是 **context-fragment.v1** JSON,写入 tag「设计.美学纲领与交互范式」。
|
||||
业务字段全部放在「正文」内;须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
|
||||
业务字段全部放在「正文」内(三柱为**对象**,不要写成一段散文);须含 schema / 技能 / brief;建议填 mount 与稳变(本步不写数字 order)。
|
||||
由运行时按模型能力做 schema/tool 投递,不要再口头包一层 outputs 字符串。
|
||||
|
||||
一次 design-step、一场对话、一次验收。参与方式与美学内容在同一步内交叉追问,不要先交「半份交互」再开「半份美学」。
|
||||
用户已答过追问:把答复写入正文三块,`追问.题目` 优先 `[]`;禁止在「交互范式」与「美学纲领」之间轮流补洞拖验收。核心体验与权限边界够用即可收。
|
||||
@@ -193,17 +194,18 @@ feeds: design_only
|
||||
|
||||
硬规则(公共三段 + 本技正文):
|
||||
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
|
||||
2. **公共三段**:`正文`=产物主体;`自评`=完备度打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
|
||||
3. **本技正文三块**:`设定逻辑`(参与/内容维度/区域/复杂性/完备度)+ `交互范式` + `美学纲领`。未知省略或「未定」,禁止编造百科。
|
||||
4. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
|
||||
5. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
|
||||
6. summary:`美学纲领与交互范式 · {brief 缩略}`。
|
||||
2. **公共三段**:`正文`=产物主体;`自评`=打分(**分数 0–10 十分制,禁止百分数**);`追问`=导语+题目(建议选项/示例)。禁止用散文代替这三段。
|
||||
3. **正文深度**:三柱(设定逻辑 / 交互范式 / 美学纲领)为对象。柱下到「条目」为止:条目是短字段、string[],或诊断三元组 `{结论, 依据, 完备度}` / `{已知, 待探, 完备度}`。诊断里的结论/依据/已知/待探必须是字符串。禁止把整柱写成一段 text;禁止在诊断或短字段里再套对象。
|
||||
4. **本技三柱**:`设定逻辑`(变造 + 参与方式 + 内容维度 + 区域/复杂性/完备度)+ `交互范式`(前置配置/视角/权限/后果)+ `美学纲领`(体验内核 + 呈现要点)。未知省略或「未定」,禁止编造百科。
|
||||
5. `mount` 建议挂主世界层+转述;**禁止**写数字 `order`。`稳变` 定稿后多为 `stable`。
|
||||
6. `追问.题目` 一次宜 1~3 题;有建议选项与示例;允许「其它」。已答清的不要再问。题目只写在「追问」,askUser 为 null。
|
||||
7. summary:`美学纲领与交互范式 · {brief 缩略}`。
|
||||
|
||||
## checklist
|
||||
|
||||
```checklist
|
||||
- [ ] 含 schema / brief / 正文 / 自评 / 追问 三段外壳?
|
||||
- [ ] 正文含设定逻辑 + 交互范式 + 美学纲领?自评有交互/美学/协调维度?
|
||||
- [ ] 正文三柱是对象(非一段散文)?设定逻辑里诊断有结论/依据/完备度?自评有交互/美学/协调维度?
|
||||
- [ ] 未写 order、未输出执行单元列表?
|
||||
- [ ] brief 能点题核心体验?变造/代入/满足来源清楚或已标未定?
|
||||
- [ ] 交互范式权限与美学纲领互相保护、不矛盾?
|
||||
@@ -215,11 +217,12 @@ feeds: design_only
|
||||
|
||||
```examples
|
||||
好:
|
||||
- brief 点题「垄断生存资源的绝对掌控感」;正文三块齐(设定逻辑含完备度/待探、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。
|
||||
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳。
|
||||
- brief 点题「垄断生存资源的绝对掌控感」;正文三柱为对象(设定逻辑含诊断三元组、交互范式锁权限、美学纲领写体验内核);自评打分;追问带建议选项打薄弱点。
|
||||
- 内容质感可参考「权力孤岛」类长描写,但必须落在上述 JSON 键里,不要输出 XML/自定义标签壳,也不要把三柱收成一段 text。
|
||||
|
||||
坏:
|
||||
- 只有散文纲领,无 JSON / 无自评 / 无追问结构
|
||||
- 三柱是 sections[].text 一段话,验收卡无法切开诊断与契约
|
||||
- 百科式背景堆砌,无满足来源与权限边界
|
||||
- 输出 workers[] 或 order 表
|
||||
- 追问空洞「你想要什么体验?」无选项
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# declaration = catalog 短声明(可被 prompt.md ```meta 覆盖)
|
||||
# artifact = 执行期产物 tag(程序映射;不写进流程 JSON)
|
||||
# repeatable = true 时允许同能力多次**新建**编入增量 DAG(如生成规则、具体实例)。
|
||||
# 回头修改已完成节点不是 repeatable:DAG 用 mode=revise + revises,继承旧产物。
|
||||
# 回头修改已完成节点不是 repeatable:用户在图上点该节点重进,不要排 mode=revise。
|
||||
# closer = true 时为创作终节点:选定后程序落库、关 DAG、保存定稿
|
||||
# auto = true 时为程序步:不抛默认问题、不经「同意并开始」,提案后直接执行(产物仍验收)
|
||||
# kind = 节点特性。缺省=普通执行步。prior-artifact=先验产物(须先定写什么;params 是规划产物,不拦执行)
|
||||
@@ -21,7 +21,7 @@
|
||||
#
|
||||
# 世界模拟器常用能力见下「体验→世界→机制→呈现→收成」;编排按需选用,勿默认全选。
|
||||
# 流程是可变增量 DAG:勿一次排完全程;可反复调用标了 repeatable 的能力(每次=彻底新建)。
|
||||
# 已完成节点要改:编排 mode=revise(继承旧稿),不要再排一条 fresh。
|
||||
# 已完成节点要改:用户在图上点该节点重进(继承旧稿),不要再排一条 fresh 或 mode=revise。
|
||||
# 有编排参数的普通能力:DAG 步骤必须是带 params 的具体调用,禁止空壳进 design-step。
|
||||
# 〔先验产物〕例外:params 是规划产物,可空;「写什么」在步内钉。
|
||||
|
||||
@@ -77,7 +77,7 @@ modules:
|
||||
hint: 本批要锚定什么(如开局重要NPC)
|
||||
- key: count
|
||||
label: 数量
|
||||
hint: 数字或「按规则」;contextual 时写清规模依据
|
||||
hint: 同批时本批条数;规则「一次生成」为单条时忽略(本步只交 1 条)
|
||||
|
||||
- id: narrative
|
||||
name: 叙事指南与故事推进
|
||||
|
||||
@@ -28,10 +28,10 @@ feeds: gm
|
||||
|
||||
```opening
|
||||
先认定本批按哪条规则生成:
|
||||
- 若【规划产物】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
|
||||
- 若【本步对象】已有 rule_id,以此为起点;要换规则就在本步改(须仍是已验收的预生成规则)。
|
||||
- 若规划为空,从已验收生成规则里选出一条预生成规则。
|
||||
|
||||
有批次目标或数量偏好可一句带过;否则直接「按规则生成」。
|
||||
读规则「一次生成」:同批则本步一次交齐互有关系的多条;单条则本步只交 1 条。有批次目标可一句带过。
|
||||
```
|
||||
|
||||
## task
|
||||
@@ -41,12 +41,15 @@ feeds: gm
|
||||
|
||||
本步 = 规则执行器。禁止重议对象族、生命周期、schema 或描写方法。
|
||||
|
||||
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【规划产物】当起点;规划可空。
|
||||
本步是〔先验产物〕:先定调用哪条规则,再生成 records。读【本步对象】与【原型建议】;在本步钉「写什么」。
|
||||
|
||||
执行顺序:
|
||||
1. 认定 `rule_id`:规划有则采用(用户要换则以最新为准,须仍是已验收的预生成规则);空则先从依赖产物里选出。不要停下来让编排器补参。非预生成生命周期或缺信息 → 停止并指出缺口。
|
||||
2. 数量:规划 `count` 有数字用数字,否则用规则「数量」;`batch_goal` 只作不违反规则的筛选。
|
||||
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(单层 JSON;键=schema 顶层字段)。
|
||||
2. 条数:先读规则「一次生成」。
|
||||
- `单条`:本批 `records` 恰好 1 条;规划 `count` 即使写成多条也忽略。
|
||||
- `同批`:一次交齐互有关系的多条。条数:规划 `count` 有数字用数字,否则用规则「数量」。生成时先对齐「一次生成.关系」(各条如何互指、对立、同场),再填各条字段。
|
||||
`batch_goal` 只作不违反规则的筛选。缺「一次生成」时按「数量」/ `count` 交 `records[]`(可多条)。
|
||||
3. 完全按规则的「生成与描写」+「产物格式」+「池」生成 `records`(始终是数组,单条也是长度为 1 的数组;键=schema 顶层字段)。
|
||||
4. 按规则自检;不合格内部重做。已有产物则追加批次(除非用户要求替换)。
|
||||
5. 输出 JSON;追问通常 `[]`。
|
||||
|
||||
@@ -56,15 +59,16 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
## principles
|
||||
|
||||
```principles
|
||||
1. 只按规则来:方法、格式、池、数量、约束——规则有什么用什么;没有的不发明。
|
||||
2. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
|
||||
3. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
|
||||
1. 只按规则来:方法、格式、池、数量、一次生成、约束——规则有什么用什么;没有的不发明。
|
||||
2. 同批先钉记录间关系再填各条;单条不虚构同场关系。始终交 `records[]`。
|
||||
3. 只交 records:单层 JSON;不交解释、草稿、schema 副本、整池粘贴。
|
||||
4. 要改合同 → 回「生成规则」,本步不加字段、不改枚举。
|
||||
```
|
||||
|
||||
## probe
|
||||
|
||||
```probe
|
||||
通常不追问。仅当 contextual 数量算不出时问一句规模依据。
|
||||
通常不追问。仅当 contextual 数量算不出、或同批「关系」写了但本批对象对不齐时,问一句。
|
||||
用户要 schema 外内容 → 提示回「生成规则」。
|
||||
```
|
||||
|
||||
@@ -110,7 +114,7 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
硬规则:
|
||||
1. 合法 JSON;含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`。
|
||||
2. 正文键固定:`rule_id` / `本批数量` / `批次条件` / `batch_id` / `records` / `增量说明`。
|
||||
3. `records` 每条必须是规则约定的单层 JSON;`本批数量 === records.length`。
|
||||
3. `records` 始终为数组;每条必须是规则约定的单层 JSON;`本批数量 === records.length`。规则「一次生成」为单条时长度为 1;为同批时 ≥2 且关系对齐。
|
||||
4. 自评仅「合规模」一维。禁止改规则、写 `order`、输出执行单元列表。
|
||||
5. summary:`具体实例 · {对象} · {n}条`。
|
||||
|
||||
@@ -118,6 +122,7 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
|
||||
```checklist
|
||||
- [ ] 按本步认定的 rule_id(规划或步内钉)的预生成规则执行?
|
||||
- [ ] 一次生成已遵守(单条=1 条;同批=多条且关系对齐)?records 始终是数组?
|
||||
- [ ] records 单层、键值符合产物格式与全部约束?
|
||||
- [ ] 自评为合规模?未改合同?
|
||||
```
|
||||
@@ -125,6 +130,8 @@ summary:`具体实例 · {对象} · {n}条`
|
||||
## examples
|
||||
|
||||
```examples
|
||||
好:规则要 3 条怪物 → 交出 3 条单层 record,字段与池用法全服从规则。
|
||||
好:规则一次生成=同批、关系=开局仇敌二人 → 本批 2 条,对立在两条里对齐。
|
||||
好:规则一次生成=单条 → 本批 1 条;还要更多就再点原型。
|
||||
坏:同批规则却只交 1 条,或分多步把有关系的人拆开写。
|
||||
坏:加规则没有的字段;输出一段「我是怎么想的」却没有 records;本步改 schema。
|
||||
```
|
||||
|
||||
@@ -37,13 +37,14 @@ feeds: gm,auditor
|
||||
|
||||
```opening
|
||||
先认定本轮规则写哪一类内容:
|
||||
- 若【规划产物】已有对象,以此为起点;要改对象就在本步改。
|
||||
- 若【本步对象】/【原型建议】已有对象提示,以此为起点;要改对象就在本步改。
|
||||
- 若规划为空,先钉对象族,再谈规则。
|
||||
|
||||
然后谈:
|
||||
1. 若不做专门规则、只靠世界设定临场写,最怕出现什么不可接受的结果?
|
||||
2. 更需要:开局先钉一批、游玩中再随机补,还是只在游玩期动态生成?
|
||||
3. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
|
||||
3. 这类实例彼此有没有必须同场钉死的关系(如互为亲属、对立、同场搭档)?有 → 一次同批生成;彼此独立 → 一次一条。
|
||||
4. 哪些产物字段需要「池」?(池挂在 JSON 字段上,如气质标签池→`气质`;可多个;没有就说没有)
|
||||
|
||||
想到什么写什么;完整规则在正式产物里钉。
|
||||
```
|
||||
@@ -59,7 +60,7 @@ feeds: gm,auditor
|
||||
3. **池**(可选):每个池挂在产物格式里的某个字段(变量)上,供该字段抽取/组装/枚举;
|
||||
一条规则可多个池;无则 `池: []`。
|
||||
|
||||
本步是〔先验产物〕:先定本轮对象族,再写规则。读【规划产物】当起点;规划可空。禁止顺手扩到其它族。
|
||||
本步是〔先验产物〕:先定本轮对象族,再写规则。读【本步对象】与【原型建议】;在本步钉「写什么」。禁止顺手扩到其它族。
|
||||
|
||||
执行顺序:
|
||||
1. 认定对象族:规划有 `target` 则采用(用户要改则以最新为准);规划为空则先钉对象。不要停下来让编排器补参。
|
||||
@@ -68,14 +69,17 @@ feeds: gm,auditor
|
||||
- `runtime_only`:仅游玩期动态生成;不要求「具体实例」。
|
||||
- `seed_only`:由「具体实例」预生成;规则用完可丢。
|
||||
- `seed_and_runtime`:先种子实例;规则与实例都保留,游玩期仍可按同规则生成。
|
||||
4. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
|
||||
5. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
|
||||
6. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
||||
4. 钉「数量」与「一次生成」(两件不同的事):
|
||||
- **数量**:这类对象总共要有几条(fixed / range / contextual)。
|
||||
- **一次生成**:点一次「具体实例」交几条。彼此有必须同场对齐的关系 → `同批`(一次交出并对齐关系);彼此独立 → `单条`(一次只交 1 条,要更多就再点原型)。产物形状始终是 `records[]`,不因单条改成单对象。
|
||||
5. 写满规则三块:生成与描写(可较长)+ 产物格式(**单层 JSON**:每条产物是一层键值,无嵌套对象)+ 池(需要才填;绑顶层字段)。
|
||||
6. 逐字段过筛:每个属性都要能回答「对本对象族是否必要」。冗余字段删掉(如女主规则里的「性别」);对象族必需字段不得漏(如 D&D 怪物的等级、攻击骰)。
|
||||
7. `rule_id`:规划已给则用;否则稳定 kebab-case。已有产物按 rule_id 增量。
|
||||
若本步对象与规划不一致,以最新对象为准并重钉对应英文 rule_id,禁止沿用旧 id。
|
||||
7. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
||||
8. 按自评三维打分并填追问;输出 JSON。禁止夹带最终实例 records。
|
||||
|
||||
若程序已发默认问题:禁止重复开场;在首答上补洞。
|
||||
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
|
||||
summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生成并动态} · {单条|同批}`;跳过 → `生成规则 · {对象} · 无需专门规则`。
|
||||
```
|
||||
|
||||
## principles
|
||||
@@ -90,7 +94,8 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
类型/必填/枚举/上下界必须可校验;禁止只给示例让下游猜类型。
|
||||
6. 池绑顶层字段名(如 `气质`、`攻击花招`);一条规则可多池。池条目是该字段材料,不是整条 record。
|
||||
7. 生命周期先行;未定前不假装可执行「具体实例」。
|
||||
8. 不写变量更新公式、不写剧情推进、不填最终实例。
|
||||
8. **一次生成**看关系,不看「想不想一次多写」:有必须同场钉死的关系(亲属、对立、同场搭档等)→ `同批`;彼此独立 → `单条`。`同批` 至少 2 条,否则改 `单条`。不要把「总共要几个」(数量)和「这一次交几条」(一次生成)混成一件事。
|
||||
9. 不写变量更新公式、不写剧情推进、不填最终实例。
|
||||
```
|
||||
|
||||
## probe
|
||||
@@ -98,7 +103,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
```probe
|
||||
每轮 1~2 点。规划已钉且用户未改对象时,禁止重问 target / rule_id。规划为空或用户要换对象时,先问本轮生成什么。
|
||||
|
||||
优先:整条规则是否必要;某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
|
||||
优先:整条规则是否必要;实例彼此有无必须同场对齐的关系(一次生成:同批 vs 单条);某字段该不该进表(冗余 vs 漏接口);单层格式类型/枚举/边界;哪些字段要池。
|
||||
若双门槛不成立 → 建议跳过,停追问字段与格式。
|
||||
不追问:最终实例正文、变量增减公式、其它对象族、完整世界百科。
|
||||
```
|
||||
@@ -141,6 +146,10 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
"最大": 1,
|
||||
"决定规则": "contextual 时必填,可较长"
|
||||
},
|
||||
"一次生成": {
|
||||
"模式": "单条|同批",
|
||||
"关系": "同批必填:本批记录之间必须一起钉的联系;单条写「彼此独立」"
|
||||
},
|
||||
"生成与描写": {
|
||||
"依据": ["可较长;引用上游产物或用户要求"],
|
||||
"方法": ["可较长:生成顺序、如何组装、描写口径、语气/切面、如何用池"],
|
||||
@@ -236,7 +245,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
1. 合法 JSON;必含 `schema` / `技能` / `brief` / `正文` / `自评` / `追问`(题目可 `[]`)。
|
||||
2. **公共三段**:正文=规则主体;自评=必要性/属性妥当/格式准确(**分数 0–10 十分制**);追问=导语+建议选项。
|
||||
3. **正文键固定**:本步参数、必要性判断、rules、增量说明。
|
||||
4. **每条 rule 必含**:生成与描写、产物格式;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。
|
||||
4. **每条 rule 必含**:生成与描写、产物格式、数量、一次生成;**池可空数组**。有池时 `绑定字段` 必须是单层 schema 的顶层键。`一次生成.模式` 为同批时 `关系` 必填且本批至少 2 条;为单条时一次只交 1 条。
|
||||
5. **产物格式强制单层**:`schema` 的 `type` 不得为 `object`;`array` 的 `items` 不得为 object。禁止 `外貌.发色` 这类嵌套路径。
|
||||
6. 结论为「无需专门规则」时:`rules` 必须 `[]`。上下文策略布尔须与生命周期一致。
|
||||
7. 池条目≠最终实例;禁止输出具体实例 `records`、变量更新规则、执行单元列表。
|
||||
@@ -248,7 +257,7 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
- [ ] 含 schema / brief / 正文 / 自评 / 追问?
|
||||
- [ ] 自评为必要性 / 属性妥当 / 格式准确?
|
||||
- [ ] 整条规则必要性有举证?不成立时 rules=[]?
|
||||
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?
|
||||
- [ ] 建立时:逐字段无冗余无漏接口?产物为单层 JSON 且类型可校验?数量与一次生成已分清(总共几个 vs 这一次交几条)?同批有关系、单条写彼此独立?
|
||||
- [ ] 池:无则 [];有则绑顶层字段、未写成最终实例表?
|
||||
- [ ] 未夹带最终实例、变量公式、其它对象族、百科?
|
||||
```
|
||||
@@ -259,11 +268,15 @@ summary:建立 → `生成规则 · {对象} · {仅动态|仅预生成|预生
|
||||
好:
|
||||
- D&D 怪物:单层含 名称/等级/攻击骰/……;池可绑 `气质`;自评属性妥当高(未漏战斗接口)。
|
||||
- 女主规则:不设「性别」字段;关系切面进表;池可空。
|
||||
- 互为仇敌的两名开局 NPC:一次生成=同批,关系写清对立如何对齐;数量=2。
|
||||
- 可互换的酒馆路人:一次生成=单条,关系写「彼此独立」;要 5 个就点 5 次。
|
||||
- 事件钩子:池绑顶层 `钩子`;方法写抽 1~2 条写入该字段。
|
||||
- 普通古董 → 无需专门规则(必要性维度说明跳过理由)。
|
||||
|
||||
坏:
|
||||
- 产物写成 `{ "外貌": { "发色": "…" } }` → 非单层,前端表格/投影难对接。
|
||||
- 女主规则塞「性别」;或怪物规则只有「名字+性格」没有等级/攻击骰。
|
||||
- 有亲属关系的三人却标成单条,分三次各写一条,关系对不上。
|
||||
- 把「总共要 5 个」写成一次生成=同批,但彼此并无必须同场的关系。
|
||||
- 把写好的 NPC 整份塞进池当最终实例。
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ uiPrompt: |
|
||||
|
||||
你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点:
|
||||
|
||||
- 没有 DAG 或 DAG 需要追加节点:`design-flow`
|
||||
- DAG 已给出下一个可执行节点:`design-step`
|
||||
- 没有 DAG、需要追加节点,或用户要求补前序节点(即使下一步已是收口):`design-flow`
|
||||
- DAG 已给出下一个可执行节点、且用户没有要求改排:`design-step`
|
||||
|
||||
节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。
|
||||
|
||||
@@ -3,8 +3,8 @@ id: design-flow
|
||||
skill: world-simulator
|
||||
name: 创作 · 流程编排
|
||||
description: >-
|
||||
【编排】以用户已选配方为起点,从能力池排出近期工序与依赖,
|
||||
产出/修订可变增量 DAG(设计.创作流程)。本步不写美学/机制正文,不写 Worker 集。
|
||||
【编排】以用户已选配方为起点,从能力池排出近期 DAG 骨架(节点 + 依赖 + 原型槽位 + 建议)。
|
||||
不决定执行顺序;用户在图上点选。本步不写美学/机制正文。
|
||||
version: 1
|
||||
stage: design
|
||||
inputTags:
|
||||
@@ -40,70 +40,101 @@ contextSegments:
|
||||
|
||||
# 创作 · 流程编排
|
||||
|
||||
你只做一件事:根据【用户要求】编排或**增量修订**一份剧本流程(可变 DAG)。要求按先后都有效,冲突时以较新的为准。
|
||||
你只做一件事:根据【用户要求】编排或**增量修订**一份 **DAG 骨架**。要求按先后都有效,冲突时以较新的为准。
|
||||
|
||||
**你不决定**用户下一步先跑哪个——用户在分层图上点选。你只产出:有哪些节点、depends_on、原型槽位(repeatable 能力)、以及建议。
|
||||
|
||||
上下文里会有:
|
||||
|
||||
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、哪些能力才允许再编入。必须先读完再排。
|
||||
2. **【用户已选配方】**:该方法的适用、核心思路、设计流程与原则 + 近期起点 steps——**不是**锁死流水线;**禁止**替用户改选其它配方
|
||||
3. **【能力 · 可选工序】**:固定中文名 + 声明 + **何时用 / 何时不用 / 边界**(来自各能力 meta)+(若有)编排参数——步骤只能从这里选;标〔可反复〕的可多次**新建**编入;已完成节点要改用回头修改(mode=revise)
|
||||
4. **【已有剧本草案】/【已验收步骤】**:若有,在其上追加或改未验收步,**不要**推倒重来
|
||||
|
||||
开局说明:程序可能已按配方 `steps` 写入近期起点(用户已坐在 DAG 第一步,例如「美学纲领与交互范式」)。此时**不要**把开局步再规划一遍;仅追加缺口。listed 步已齐仍需扩写时,保留已完成 id,只加新步。
|
||||
1. **【流程进度】**(程序钉死):已完成哪些步、草案里已有哪些步、原型槽位。必须先读完再排。
|
||||
2. **【用户已选配方】**:方法论 + 近期起点——**不是**锁死流水线
|
||||
3. **【能力 · 可选工序】**:步骤只能从这里选
|
||||
4. **【已有剧本草案】/【已验收步骤】**:在其上追加,**不要**推倒重来
|
||||
|
||||
## 增量 DAG(核心)
|
||||
|
||||
**禁止**一次排完全程固定长链。每次只排出**近期要做**的步骤(通常 1~4 步),`status` 默认 `"open"`。
|
||||
**禁止**一次排完全程固定长链。每次只排出**近期 horizon**(通常 1~4 个节点 + 若干原型),`status` 默认 `"open"`。
|
||||
|
||||
选型规则:
|
||||
### 节点类型
|
||||
|
||||
- **跟配方方法论**:用配方的 core / process / principles 理解整局怎么设计、何时收成。
|
||||
- **跟能力 meta**:某步该不该排,看能力的「何时用 / 何时不用 / 边界」;**不要**在配方里找各能力调用条件,也不要凭题材默认全选。
|
||||
- 〔先验产物〕(生成规则、具体实例):可提前规划「写什么」写入 params;执行时程序注入【规划产物】。规划可空——对象在步内钉。提前规划多条(不同对象/批次)是正确的。**禁止**为钉「生成什么 / 调用哪个规则」而 askUser 拦在确认开干前。
|
||||
- 其它有「编排参数」的能力:写入 DAG 时可先留空壳;**确认开干前**必须钉齐必填 params。
|
||||
- 用户先认可**下一节点**,再进入执行;用户提出新要求时,再调本 worker 修订未验收步或追加新步。
|
||||
| 类型 | JSON | 谁跑 |
|
||||
|------|------|------|
|
||||
| 普通一步 | 缺省(无 role) | 用户点上直接 design-step |
|
||||
| **原型槽位** | `"role": "prototype"` | 用户点上 → 程序增殖 instance → design-step |
|
||||
| **实例** | `"role": "instance"`, `"from": "<原型 id>"` | 仅程序增殖,**禁止**你在编排层预排 |
|
||||
|
||||
同能力**可以**多次出现。必须分清两种:
|
||||
**禁止**排 `mode=revise` / 回头修改节点。已完成节点要改:用户在图上点该节点重进,不在 DAG 里加修订步。
|
||||
|
||||
- **彻底新建**(〔可反复〕如生成规则、具体实例):`mode` 缺省或 `"fresh"`,新 id + 新 params。每一次都是新对象/新批次,**不要**改旧条。id 应尽量带上调用目标,如 `生成规则·怪物`,避免无信息的 `#2`。
|
||||
- **回头修改**:用户要改**已经做过**的节点(漏了、谈浅了、后来发现要改)→ 追加新 id,`"mode": "revise"`,`"revises": "<原步 id>"`。程序会注入既有产物,在旧稿上改。非反复技能**禁止**再排一条 fresh;只能 revise。插在最近已验收步之后、尚未执行的步之前。
|
||||
### 〔可反复〕= 原型,不是多条 instance
|
||||
|
||||
生成规则、具体实例等 catalog 标〔可反复〕的能力:
|
||||
|
||||
- 在 `steps` 里写 **`role: "prototype"`** 一条即可(id 通常与 name 相同,如 `"生成规则"`)
|
||||
- 写 **`suggestion`**:建议这条原型将来生成什么(短句,给用户和实例步参考)
|
||||
- **禁止**在原型上写 `params`
|
||||
- **禁止**预排 `role: "instance"` 或 `生成规则#2` 这类实例——用户在图上点原型,程序增殖
|
||||
|
||||
### `suggestions`(流程级)
|
||||
|
||||
除 `steps` 里的原型外,可在顶层写 **`suggestions`**:建议用户**还可以**追加哪些原型(尚未编入 steps 时展示在图上)。
|
||||
|
||||
```json
|
||||
"suggestions": [
|
||||
{
|
||||
"name": "生成规则",
|
||||
"suggestion": "怪物种群与 NPC 两套规则,各开一条",
|
||||
"depends_on": ["美学纲领与交互范式"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
若已把该能力编入 `steps` 为 prototype,不必在 suggestions 重复。
|
||||
|
||||
### 〔先验产物〕
|
||||
|
||||
生成规则、具体实例:**「写什么」在实例步内钉**,不在编排层 params。原型只带 `suggestion`。
|
||||
|
||||
### 选型规则
|
||||
|
||||
- 跟配方 core / process / principles
|
||||
- 跟能力 meta 的何时用/不用
|
||||
- 用户要补节点 → 插在未验收步之前、收口之前,`status: "open"`
|
||||
|
||||
## 产出(唯一)
|
||||
|
||||
写入 tag `设计.创作流程`,必须是 JSON 对象,形状:
|
||||
写入 tag `设计.创作流程`。根对象就是下面这份 JSON(必须含 `steps`),不要改写成 `artifact.flat.v1` 的 sections。
|
||||
|
||||
```json
|
||||
{
|
||||
"brief": "一句话复述用户要的体验(可选)",
|
||||
"brief": "一句话体验(可选)",
|
||||
"status": "open",
|
||||
"suggestions": [
|
||||
{
|
||||
"name": "具体实例",
|
||||
"suggestion": "开局 2~3 个重要 NPC",
|
||||
"depends_on": ["生成规则"]
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{
|
||||
"id": "生成规则·怪物",
|
||||
"id": "生成规则",
|
||||
"name": "生成规则",
|
||||
"params": {
|
||||
"target": "怪物",
|
||||
"rule_id": "monsters",
|
||||
"lifecycle_intent": "runtime_only"
|
||||
},
|
||||
"role": "prototype",
|
||||
"suggestion": "先写怪物种群生成规则",
|
||||
"depends_on": ["美学纲领与交互范式"]
|
||||
},
|
||||
{
|
||||
"id": "具体实例·重要NPC·开局",
|
||||
"id": "具体实例",
|
||||
"name": "具体实例",
|
||||
"params": {
|
||||
"rule_id": "major-npcs",
|
||||
"batch_goal": "开局重要NPC",
|
||||
"count": "按规则"
|
||||
},
|
||||
"depends_on": ["生成规则·重要NPC"]
|
||||
"role": "prototype",
|
||||
"suggestion": "锚定 1~2 个开局 NPC",
|
||||
"depends_on": ["生成规则"]
|
||||
},
|
||||
{
|
||||
"id": "美学纲领与交互范式·改",
|
||||
"name": "美学纲领与交互范式",
|
||||
"mode": "revise",
|
||||
"revises": "美学纲领与交互范式",
|
||||
"depends_on": ["实现机制"]
|
||||
"id": "开场白与开场变量",
|
||||
"name": "开场白与开场变量",
|
||||
"depends_on": ["美学纲领与交互范式", "具体实例"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -111,44 +142,20 @@ contextSegments:
|
||||
|
||||
规则:
|
||||
|
||||
1. `steps` 数组顺序 = **建议执行顺序**(依赖须指向更前的步骤)
|
||||
2. `name` = 能力池里的固定中文名(可重复)
|
||||
3. `id` = 本局步骤唯一键;同 name 多次时必须不同;新建优先语义化(`生成规则·{对象}`);回头修改用 `{原id}·改`
|
||||
4. `depends_on` = 其它步骤的 **id**(若某 name 在本流程唯一,也可写该 name)
|
||||
5. `params` = 本步调用参数(对象)。〔先验产物〕:此处是规划产物(可空;已知对象就填,便于区分多条)。其它能力目录声明了编排参数时,**必填项必须写出**;可选则能推断就写。回头修改可省略,程序会从 `revises` 那步继承
|
||||
6. `status`:`"open"` = 还可能追加;`"closed"` = 不再扩步(可走收成)
|
||||
7. 已验收步骤的 id **必须保留**;只能追加新步,或改未验收步的依赖/顺序/params
|
||||
8. 已完成的**非反复**技能禁止再排一条 `mode=fresh`;要改旧稿只能追加 `mode=revise` + `revises`。〔可反复〕再编入新 id 且 fresh = 彻底新建(新对象/新批次),不是改旧条;改旧条同样用 revise
|
||||
9. 配方建议 steps 只作近期起点;按能力 meta 按需选用,勿默认全选、勿一次排满
|
||||
10. **禁止**把「交互」与「美学」拆成两步
|
||||
11. **禁止**在本步写能力正文、自由 Worker 列表、表结构
|
||||
12. **禁止**编排「反复发明执行单元」;收成链:确认「游玩拓扑」→(上下文已多时)「上下文投影排序」→「细化终稿」→(可开玩时)「开场白与开场变量」。挂谁可早、排第几须晚,勿让中段技能写死 order
|
||||
13. 「开场白与开场变量」标〔收口〕:排在细化终稿之后作为最后一步;排入后不要再追加其它步,可将 `status` 设为 `closed`。选定开场由程序落库并保存定稿,不必再调 opening-generator
|
||||
14. 参数或选型不够时,用 askUser 问 1~2 点(**优先带 options,并允许其它**)。
|
||||
只许问**排布层**的事:下一步排哪个能力、普通节点的必填 params 取什么值、是新建还是回头修改哪一步。
|
||||
〔先验产物〕的「生成什么 / 调用哪个规则」在步内钉,**不要**为此 askUser。
|
||||
**禁止**问体验偏好、设定细节、口味倾向等能力正文问题——那是 design-step 的活;
|
||||
已验收步骤里问过的题,**禁止**再抛一遍(用户会读成「那一步要重做」)
|
||||
15. 依赖真实产物的步骤(如具体实例依赖某条生成规则)须等上游验收后再编排,并从产物中列出可选项供用户选
|
||||
16. `summary`:`流程 · N 步 · open|closed · …`
|
||||
17. **改对象必须改后台 id**:用户把「生成规则」从女租客改成丧尸怪物时(编排修订或步内改规划),须同步改
|
||||
`params.target`、`params.rule_id`(对应英文 kebab-case,如 `zombies`)、以及步骤 `id`(如 `生成规则·丧尸怪物`)。
|
||||
**禁止**只改中文对象却沿用旧 `rule_id` / 旧步骤 id。
|
||||
这是**改这条规则的对象**(回头修改或改未验收步),与「再来一条新的生成规则」不是一回事。
|
||||
1. `steps` 顺序 = 建议阅读顺序;`depends_on` 须指向更前步骤 id
|
||||
2. `name` = 能力池固定中文名
|
||||
3. `id` = 本局唯一;原型 id 稳定(如 `生成规则`);实例 id 由程序增殖(`生成规则#1`…)
|
||||
4. **禁止**在编排层为 repeatable 预排 instance 或写 params
|
||||
5. 已验收 id **必须保留**
|
||||
6. 非反复已完成 → 禁止再排新建,**禁止**排 `mode=revise`
|
||||
7. 收口「开场白与开场变量」排最后;选定前用户补节点 → 插收口前、`status: "open"`
|
||||
8. askUser 只问排布:补哪个原型、改依赖。**禁止**问体验正文,**禁止**问要不要回头修改
|
||||
9. `summary`:`流程 · N 步 · open|closed · …`
|
||||
|
||||
## 自检
|
||||
|
||||
- 用户是否已选配方?(未选则不要硬编)
|
||||
- 是否只排了近期 horizon,而不是假固定全图?
|
||||
- 是否误排了多次「发明 Worker」?应改为至多一次「游玩拓扑」
|
||||
- 每步是否按能力 meta 的何时用/不用判断过,而非题材默认?
|
||||
- 每步 name 都在【能力】里?同名多次是否都有不同 id?
|
||||
- 有编排参数的普通步骤,必填 params 是否已钉齐?缺参是否应先 askUser 而不是产出空壳?
|
||||
- 〔先验产物〕是否误把「生成什么」当成确认开干前必填?规划可空;已知对象才写入 params
|
||||
- depends_on 是否都指向更靠前的步骤 id?
|
||||
- 已验收 id 是否都还在?
|
||||
- 【流程进度】里已完成的非反复技能,是否误当成 fresh 又排了一次?要改旧稿应 mode=revise
|
||||
- askUser 是否只问了排哪步 / 填哪个参数 / 新建还是回头修改,而不是体验偏好等正文问题?
|
||||
- 需要反复补同类内容时,是否用了新 id + 新 params + fresh 追加(彻底新建),而不是改写旧步?
|
||||
- 用户要改已完成节点时,是否用了 mode=revise + revises,而不是再排一条 fresh?
|
||||
- 收成前是否把 `status` 设为 `closed`?
|
||||
- 只排了骨架,没有替用户决定顺序?
|
||||
- repeatable 是否都是 `role=prototype` + suggestion,没有 instance/params?
|
||||
- suggestions 是否简短、可执行?
|
||||
- depends_on 是否合法(具体实例原型依赖生成规则原型 = 至少一条规则实例验收后才可增殖)?
|
||||
- 已验收 id 是否都在?
|
||||
|
||||
@@ -42,14 +42,14 @@ contextSegments:
|
||||
同能力可多次出现(不同 id):
|
||||
|
||||
- **新建**(mode 缺省 / fresh):只写这一次应增量补上的新内容;可合并既有同 tag 内容,但不改旧条、不假装在做别的步骤。〔可反复〕的「再来一次」属于这种。
|
||||
- **回头修改**(mode=revise):以【既有产物 · 继承修改】为底稿,改用户点名的部分,保留其余;不从零另起,不再走开场询问。
|
||||
- **回头修改**(程序注入【既有产物 · 继承修改】):用户从图上点已完成节点重进。以底稿继续改用户点名的部分,保留其余;不从零另起,不再走开场询问。
|
||||
- **按意见修改**(验收底栏):以【待改底稿】(最新产物)为底。用户要求按先后都有效,冲突时以较新的为准;未点名推翻的旧条仍须留在产物里。禁止原样交回。
|
||||
|
||||
## 执行
|
||||
|
||||
1. 认定本步要钉的事项:读【本步方法】里 `task` 开头(钉什么、写到哪个 tag)和 `meta.boundary` 的「本技能:」行。
|
||||
2. 读【用户要求】与依赖产物。若有默认问题:程序已发出,首答在「用户.worker答复」——在其答复上继续,不重复开场白。
|
||||
3. 按方法正文填产物。〔先验产物〕读【规划产物】当起点(可空、可在步内修订);其它节点编排参数见【本步参数】,直接采用。
|
||||
3. 按方法正文填产物。〔先验产物〕读【本步对象】在本步与用户钉「写什么」;可参考【原型建议】。其它节点编排参数见【本步参数】,直接采用。
|
||||
4. 产物用简洁 JSON 或结构化中文,少写机器变量名。`summary`:`{本步能力名} · …`
|
||||
|
||||
## 缺料时
|
||||
@@ -62,4 +62,4 @@ contextSegments:
|
||||
- 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。
|
||||
- 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。
|
||||
|
||||
需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。
|
||||
需要追加「再来一次」时,由编排器再调流程编排。已完成节点要改:用户自己在图上点该节点,不要让编排器加修订步。
|
||||
|
||||
+79
-28
@@ -1,6 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeCapabilities,
|
||||
probeProfileCapabilities,
|
||||
type ProfileCapabilities,
|
||||
} from "../llm/capabilities.js";
|
||||
import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
|
||||
import type { LlmConfig } from "./env.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
@@ -11,8 +17,15 @@ export type ApiProfile = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* 该配置默认思考强度(写入请求的 reasoning_effort)。
|
||||
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。
|
||||
*/
|
||||
reasoningEffort?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 能力探测结果;改 baseUrl/model 后应重测 */
|
||||
capabilities?: ProfileCapabilities;
|
||||
};
|
||||
|
||||
type ApiProfilesFile = {
|
||||
@@ -45,6 +58,16 @@ function writeFile(data: ApiProfilesFile): void {
|
||||
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/** 存盘值:合法档位原样保留;空 / auto / 非法 → undefined(不发送) */
|
||||
export function normalizeProfileReasoningEffort(
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "auto") return undefined;
|
||||
return sanitizeReasoningEffort(trimmed);
|
||||
}
|
||||
|
||||
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
|
||||
if (data.profiles.length > 0) return data;
|
||||
const env = loadLlmConfigOptional();
|
||||
@@ -79,15 +102,18 @@ export function createApiProfile(input: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort?: string;
|
||||
}): ApiProfile {
|
||||
const data = readFile();
|
||||
const now = new Date().toISOString();
|
||||
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: input.name.trim() || "未命名",
|
||||
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
|
||||
apiKey: input.apiKey.trim(),
|
||||
model: input.model.trim() || "deepseek-v4-pro",
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -98,20 +124,52 @@ export function createApiProfile(input: {
|
||||
|
||||
export function updateApiProfile(
|
||||
id: string,
|
||||
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
|
||||
input: Partial<
|
||||
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
|
||||
>,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index];
|
||||
const nextBase = input.baseUrl?.trim() || current.baseUrl;
|
||||
const nextModel = input.model?.trim() || current.model;
|
||||
const endpointChanged =
|
||||
nextBase !== current.baseUrl || nextModel !== current.model;
|
||||
const reasoningEffort =
|
||||
input.reasoningEffort !== undefined
|
||||
? normalizeProfileReasoningEffort(input.reasoningEffort)
|
||||
: current.reasoningEffort;
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
name: input.name?.trim() || current.name,
|
||||
baseUrl: input.baseUrl?.trim() || current.baseUrl,
|
||||
baseUrl: nextBase,
|
||||
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
|
||||
? input.apiKey.trim()
|
||||
: current.apiKey,
|
||||
model: input.model?.trim() || current.model,
|
||||
model: nextModel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
// 端点或模型变了,旧探测作废
|
||||
...(endpointChanged ? { capabilities: undefined } : {}),
|
||||
};
|
||||
if (reasoningEffort) updated.reasoningEffort = reasoningEffort;
|
||||
else delete updated.reasoningEffort;
|
||||
data.profiles[index] = updated;
|
||||
writeFile(data);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function saveProfileCapabilities(
|
||||
id: string,
|
||||
capabilities: ProfileCapabilities,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index]!;
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
capabilities: normalizeCapabilities(capabilities) ?? capabilities,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
data.profiles[index] = updated;
|
||||
@@ -119,6 +177,20 @@ export function updateApiProfile(
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 探测并持久化;返回更新后的 profile */
|
||||
export async function probeAndSaveProfileCapabilities(
|
||||
id: string,
|
||||
): Promise<ApiProfile> {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) throw new Error("API 配置不存在");
|
||||
const capabilities = await probeProfileCapabilities({
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
});
|
||||
return saveProfileCapabilities(id, capabilities);
|
||||
}
|
||||
|
||||
export function deleteApiProfile(id: string): void {
|
||||
const data = readFile();
|
||||
data.profiles = data.profiles.filter((p) => p.id !== id);
|
||||
@@ -130,30 +202,9 @@ export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
...(profile.reasoningEffort
|
||||
? { reasoningEffort: profile.reasoningEffort }
|
||||
: {}),
|
||||
capabilities: normalizeCapabilities(profile.capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
export async function testApiProfile(profile: ApiProfile): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${profile.apiKey}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "连接成功" };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "连接失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ProfileCapabilities } from "../llm/capabilities.js";
|
||||
|
||||
export type LlmConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* API 配置上的默认思考强度;generation 未指定时写入 reasoning_effort。
|
||||
* 空 / auto = 不发送,走供应商默认。
|
||||
*/
|
||||
reasoningEffort?: string;
|
||||
/** 来自 API profile 探测结果;决定 structured 投递选路 */
|
||||
capabilities?: ProfileCapabilities;
|
||||
};
|
||||
|
||||
export function loadLlmConfig(): LlmConfig {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* API profile 能力探测与 structured 投递选路。
|
||||
* 优先 response json_schema;不可用则 forced tool;再退 json_object。
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRejectedGenerationField,
|
||||
parseRejectedGenerationField,
|
||||
} from "./generation-compat.js";
|
||||
|
||||
export type CapabilityStatus = "ok" | "fail" | "unknown";
|
||||
|
||||
export type StructuredDeliveryMode =
|
||||
| "json_schema"
|
||||
| "forced_tool"
|
||||
| "json_object"
|
||||
| "text";
|
||||
|
||||
export type ProfileCapabilities = {
|
||||
/** 普通聊天 /models 或极简 completion */
|
||||
chat: CapabilityStatus;
|
||||
/** response_format: json_object */
|
||||
jsonObject: CapabilityStatus;
|
||||
/** response_format: json_schema(真 schema 约束) */
|
||||
jsonSchema: CapabilityStatus;
|
||||
/** tools + 返回 tool_calls */
|
||||
tools: CapabilityStatus;
|
||||
/** tool_choice=required(或指名函数)能强制提交 */
|
||||
forcedTool: CapabilityStatus;
|
||||
/** tools[].function.strict(若探测时带了) */
|
||||
strictTool: CapabilityStatus;
|
||||
testedAt?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export const UNKNOWN_CAPABILITIES: ProfileCapabilities = {
|
||||
chat: "unknown",
|
||||
jsonObject: "unknown",
|
||||
jsonSchema: "unknown",
|
||||
tools: "unknown",
|
||||
forcedTool: "unknown",
|
||||
strictTool: "unknown",
|
||||
};
|
||||
|
||||
/** 探测固定 low,避免 always-on 思考把 max_tokens 吃光导致误判 */
|
||||
export const CAPABILITY_PROBE_REASONING_EFFORT = "low" as const;
|
||||
|
||||
const PROBE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROBE_TOOL = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "submit_probe",
|
||||
description: "Submit the probe result. Always call this tool.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PROBE_TOOL_STRICT = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
...PROBE_TOOL.function,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeCapabilities(
|
||||
raw: unknown,
|
||||
): ProfileCapabilities | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const read = (k: string): CapabilityStatus => {
|
||||
const v = row[k];
|
||||
if (v === "ok" || v === "fail" || v === "unknown") return v;
|
||||
return "unknown";
|
||||
};
|
||||
return {
|
||||
chat: read("chat"),
|
||||
jsonObject: read("jsonObject"),
|
||||
jsonSchema: read("jsonSchema"),
|
||||
tools: read("tools"),
|
||||
forcedTool: read("forcedTool"),
|
||||
strictTool: read("strictTool"),
|
||||
testedAt: typeof row.testedAt === "string" ? row.testedAt : undefined,
|
||||
notes: Array.isArray(row.notes)
|
||||
? row.notes.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 选路:schema → forced tool → json_object → text */
|
||||
export function resolveStructuredDeliveryMode(
|
||||
caps: ProfileCapabilities | null | undefined,
|
||||
): StructuredDeliveryMode {
|
||||
if (!caps) return "json_object";
|
||||
if (caps.jsonSchema === "ok") return "json_schema";
|
||||
if (caps.forcedTool === "ok") return "forced_tool";
|
||||
if (caps.jsonObject === "ok") return "json_object";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function deliveryModeLabel(mode: StructuredDeliveryMode): string {
|
||||
switch (mode) {
|
||||
case "json_schema":
|
||||
return "JSON Schema";
|
||||
case "forced_tool":
|
||||
return "强制 Tool";
|
||||
case "json_object":
|
||||
return "JSON Object";
|
||||
default:
|
||||
return "纯文本";
|
||||
}
|
||||
}
|
||||
|
||||
type ProbeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
async function postOnce(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model: config.model, ...body }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, json, text };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
json: null,
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测请求始终带 reasoning_effort=low。
|
||||
* 若供应商拒收该字段,剥掉后再试一次。
|
||||
*/
|
||||
async function postChat(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const withEffort: Record<string, unknown> = {
|
||||
...body,
|
||||
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
|
||||
};
|
||||
const first = await postOnce(config, withEffort);
|
||||
if (first.ok || first.status !== 400) return first;
|
||||
|
||||
const rejected = parseRejectedGenerationField(first.text);
|
||||
if (rejected !== "reasoning_effort") return first;
|
||||
|
||||
const retryBody = { ...withEffort };
|
||||
const tried = new Set<string>();
|
||||
if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) {
|
||||
return first;
|
||||
}
|
||||
return postOnce(config, retryBody);
|
||||
}
|
||||
|
||||
function assistantMessage(json: unknown): Record<string, unknown> | null {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const choices = (json as { choices?: unknown }).choices;
|
||||
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") {
|
||||
return null;
|
||||
}
|
||||
const msg = (choices[0] as { message?: unknown }).message;
|
||||
if (!msg || typeof msg !== "object") return null;
|
||||
return msg as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasToolCallNamed(json: unknown, name: string): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
if (!msg) return false;
|
||||
const calls = msg.tool_calls;
|
||||
if (!Array.isArray(calls)) return false;
|
||||
return calls.some((c) => {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const fn = (c as { function?: unknown }).function;
|
||||
if (!fn || typeof fn !== "object") return false;
|
||||
return (fn as { name?: unknown }).name === name;
|
||||
});
|
||||
}
|
||||
|
||||
function contentLooksLikeJsonObject(json: unknown): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
const content = msg?.content;
|
||||
if (typeof content !== "string" || !content.trim()) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return parsed != null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个 profile 跑一轮能力探测(会真实打 API,费用很小)。
|
||||
*/
|
||||
export async function probeProfileCapabilities(
|
||||
config: ProbeConfig,
|
||||
): Promise<ProfileCapabilities> {
|
||||
const notes: string[] = [];
|
||||
const caps: ProfileCapabilities = {
|
||||
...UNKNOWN_CAPABILITIES,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const chat = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Reply with exactly: ok" }],
|
||||
max_tokens: 32,
|
||||
});
|
||||
caps.chat = chat.ok ? "ok" : "fail";
|
||||
if (!chat.ok) {
|
||||
notes.push(`chat: HTTP ${chat.status} ${chat.text.slice(0, 160)}`);
|
||||
caps.notes = notes;
|
||||
return caps;
|
||||
}
|
||||
|
||||
const jsonObject = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'Return a JSON object with keys ping (string "ok") and n (number 1).',
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonObject =
|
||||
jsonObject.ok && contentLooksLikeJsonObject(jsonObject.json) ? "ok" : "fail";
|
||||
if (!jsonObject.ok) {
|
||||
notes.push(`json_object: HTTP ${jsonObject.status} ${jsonObject.text.slice(0, 120)}`);
|
||||
} else if (caps.jsonObject === "fail") {
|
||||
notes.push("json_object: 返回了但不是可解析对象");
|
||||
}
|
||||
|
||||
const jsonSchema = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Fill ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "probe_result",
|
||||
strict: true,
|
||||
schema: PROBE_SCHEMA,
|
||||
},
|
||||
},
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonSchema =
|
||||
jsonSchema.ok && contentLooksLikeJsonObject(jsonSchema.json) ? "ok" : "fail";
|
||||
if (!jsonSchema.ok) {
|
||||
notes.push(`json_schema: HTTP ${jsonSchema.status} ${jsonSchema.text.slice(0, 120)}`);
|
||||
}
|
||||
|
||||
const tools = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call submit_probe with ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "auto",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.tools = tools.ok && hasToolCallNamed(tools.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!tools.ok) {
|
||||
notes.push(`tools: HTTP ${tools.status} ${tools.text.slice(0, 120)}`);
|
||||
} else if (caps.tools === "fail") {
|
||||
notes.push("tools: 请求成功但未返回 submit_probe");
|
||||
}
|
||||
|
||||
const forced = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Submit the probe.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
let forcedOk =
|
||||
forced.ok && hasToolCallNamed(forced.json, "submit_probe");
|
||||
if (!forcedOk && forced.ok) {
|
||||
const named = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: {
|
||||
type: "function",
|
||||
function: { name: "submit_probe" },
|
||||
},
|
||||
max_tokens: 256,
|
||||
});
|
||||
forcedOk = named.ok && hasToolCallNamed(named.json, "submit_probe");
|
||||
if (!named.ok) {
|
||||
notes.push(`forced_tool(named): HTTP ${named.status} ${named.text.slice(0, 120)}`);
|
||||
}
|
||||
} else if (!forced.ok) {
|
||||
notes.push(`forced_tool: HTTP ${forced.status} ${forced.text.slice(0, 120)}`);
|
||||
}
|
||||
caps.forcedTool = forcedOk ? "ok" : "fail";
|
||||
|
||||
if (caps.tools === "ok" || caps.forcedTool === "ok") {
|
||||
const strict = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL_STRICT],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.strictTool =
|
||||
strict.ok && hasToolCallNamed(strict.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!strict.ok) {
|
||||
notes.push(`strict_tool: HTTP ${strict.status} ${strict.text.slice(0, 120)}`);
|
||||
}
|
||||
} else {
|
||||
caps.strictTool = "fail";
|
||||
}
|
||||
|
||||
caps.notes = notes.length ? notes : undefined;
|
||||
return caps;
|
||||
}
|
||||
+56
-37
@@ -1,11 +1,18 @@
|
||||
import type { LlmConfig } from "../config/env.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
import {
|
||||
applyOpenAiGeneration,
|
||||
fetchWithGenerationCompat,
|
||||
sanitizeReasoningEffort,
|
||||
} from "./generation-compat.js";
|
||||
import { consumeOpenAiToolStream } from "./stream-complete.js";
|
||||
import {
|
||||
currentAbortSignal,
|
||||
throwIfAborted,
|
||||
} from "./run-abort.js";
|
||||
|
||||
export { sanitizeReasoningEffort };
|
||||
|
||||
export type ToolCallPayload = {
|
||||
id: string;
|
||||
type: "function";
|
||||
@@ -30,6 +37,8 @@ export type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
/** 部分供应商支持的严格参数约束 */
|
||||
strict?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,8 +66,16 @@ export type CompleteResult = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormat = {
|
||||
name: string;
|
||||
strict?: boolean;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CompleteOptions = {
|
||||
responseFormat?: "json_object" | "text";
|
||||
responseFormat?: "json_object" | "json_schema" | "text";
|
||||
/** responseFormat=json_schema 时使用 */
|
||||
jsonSchema?: JsonSchemaFormat;
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
@@ -66,8 +83,15 @@ export type CompleteOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ToolChoice =
|
||||
| "auto"
|
||||
| "required"
|
||||
| "none"
|
||||
| { type: "function"; function: { name: string } };
|
||||
|
||||
export type CompleteWithToolsOptions = CompleteOptions & {
|
||||
tools: ToolDefinition[];
|
||||
toolChoice?: ToolChoice;
|
||||
};
|
||||
|
||||
export type CompleteWithToolsResult = {
|
||||
@@ -104,12 +128,11 @@ export type LlmProvider = {
|
||||
): Promise<CompleteWithToolsResult>;
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
export function buildRequestBody(
|
||||
config: LlmConfig,
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const gen = options?.generation ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
model: config.model,
|
||||
messages,
|
||||
@@ -117,34 +140,28 @@ function buildRequestBody(
|
||||
|
||||
if (options?.tools?.length) {
|
||||
body.tools = options.tools;
|
||||
body.tool_choice = "auto";
|
||||
body.tool_choice =
|
||||
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
|
||||
}
|
||||
|
||||
if (gen.temperature !== undefined) body.temperature = gen.temperature;
|
||||
else body.temperature = 0.2;
|
||||
|
||||
if (gen.topP !== undefined) body.top_p = gen.topP;
|
||||
if (gen.topK !== undefined) body.top_k = gen.topK;
|
||||
if (gen.minP !== undefined) body.min_p = gen.minP;
|
||||
if (gen.frequencyPenalty !== undefined) {
|
||||
body.frequency_penalty = gen.frequencyPenalty;
|
||||
}
|
||||
if (gen.presencePenalty !== undefined) {
|
||||
body.presence_penalty = gen.presencePenalty;
|
||||
}
|
||||
if (gen.repetitionPenalty !== undefined) {
|
||||
body.repetition_penalty = gen.repetitionPenalty;
|
||||
}
|
||||
if (gen.maxOutputTokens !== undefined) {
|
||||
body.max_tokens = gen.maxOutputTokens;
|
||||
}
|
||||
if (gen.seed !== undefined) body.seed = gen.seed;
|
||||
if (gen.reasoningEffort !== undefined) {
|
||||
body.reasoning_effort = gen.reasoningEffort;
|
||||
}
|
||||
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
});
|
||||
|
||||
if (options?.responseFormat === "json_object") {
|
||||
body.response_format = { type: "json_object" };
|
||||
} else if (
|
||||
options?.responseFormat === "json_schema" &&
|
||||
options.jsonSchema
|
||||
) {
|
||||
body.response_format = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: options.jsonSchema.name,
|
||||
strict: options.jsonSchema.strict ?? true,
|
||||
schema: options.jsonSchema.schema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.stream) {
|
||||
@@ -246,19 +263,21 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||
async function llmFetch(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
body: Record<string, unknown>,
|
||||
options?: CompleteOptions,
|
||||
): Promise<Response> {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
return fetchWithGenerationCompat(async (payload) => {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
}, body);
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
|
||||
worker_questions: "执行单元提问",
|
||||
revision: "修订",
|
||||
next_intent: "下一步意向",
|
||||
pick_creation_step: "点选节点",
|
||||
};
|
||||
|
||||
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
@@ -56,9 +57,14 @@ const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
main_agent_decision_created: "总管决策",
|
||||
user_approved_next_step: "确认下一步",
|
||||
user_rejected_next_step: "拒绝下一步",
|
||||
user_requested_flow_replan: "再编排",
|
||||
user_picked_creation_step: "点选节点",
|
||||
creation_step_pick_awaited: "等待点选节点",
|
||||
user_left_creation_step: "返回节点选择",
|
||||
worker_started: "执行单元开始",
|
||||
worker_completed: "执行单元完成",
|
||||
worker_needs_input: "执行单元提问",
|
||||
worker_revision_produced_nothing: "修订无新产物",
|
||||
user_resolved_sidecar_questions: "回答挂载追问",
|
||||
user_accepted_artifact: "验收通过",
|
||||
user_rejected_artifact: "打回产物",
|
||||
@@ -78,6 +84,7 @@ const EFFECT_LABEL: Record<string, string> = {
|
||||
emit_message: "发消息",
|
||||
propose_next_creation_step: "提案下一步",
|
||||
seal_creation_opening: "收口开场白",
|
||||
unseal_creation_opening: "解开开场收口",
|
||||
run_play_turn: "开游玩回合",
|
||||
continue_play_turn: "续游玩管线",
|
||||
};
|
||||
|
||||
@@ -24,25 +24,26 @@ function buildMainAgentSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
|
||||
|
||||
- DAG 不存在或需要追加节点:调用 design-flow。
|
||||
- DAG 中已有下一个可执行节点:调用 design-step。
|
||||
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
|
||||
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
|
||||
|
||||
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
||||
${workerLines}
|
||||
|
||||
输出必须是 JSON 对象,字段:
|
||||
{
|
||||
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
||||
"reason": "string",
|
||||
"assessment": "string | null",
|
||||
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null,
|
||||
"workerId": "string | null",
|
||||
"requiresApproval": boolean,
|
||||
"workerContext": { "roleId": "string" } | null
|
||||
}`;
|
||||
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
|
||||
}
|
||||
|
||||
/** 总管把推理写成正文、未给出可用工具/JSON 时,运行时改走点选或 design-flow。 */
|
||||
export function isUnusableMainAgentOutput(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return (
|
||||
/invalid JSON/i.test(msg) ||
|
||||
/no tool calls and no content/i.test(msg) ||
|
||||
/Invalid action/i.test(msg) ||
|
||||
/must be an object/i.test(msg) ||
|
||||
/requires reason/i.test(msg) ||
|
||||
/multiple terminal tools/i.test(msg) ||
|
||||
/Not a terminal tool/i.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
export class MainAgent {
|
||||
|
||||
@@ -42,17 +42,12 @@ function buildToolLoopSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
|
||||
|
||||
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
|
||||
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
|
||||
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
|
||||
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
|
||||
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
|
||||
|
||||
当前 skill 可用 worker:
|
||||
${workerLines}
|
||||
|
||||
选定节点后立即调用 run_worker。`;
|
||||
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
|
||||
}
|
||||
|
||||
function executeLoopTool(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -160,6 +160,9 @@ export class RuntimeOrchestrator {
|
||||
case "invoke_main_agent":
|
||||
case "propose_next_creation_step":
|
||||
case "seal_creation_opening":
|
||||
case "unseal_creation_opening":
|
||||
case "run_play_turn":
|
||||
case "continue_play_turn":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+167
-10
@@ -35,6 +35,11 @@ import {
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
isOpeningSealArtifact,
|
||||
} from "../skills/opening-seal.js";
|
||||
import {
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
DESIGN_STEP_WORKER_ID,
|
||||
} from "../skills/creation-flow.js";
|
||||
import { inferLifecycleStage } from "../skills/worker-declaration.js";
|
||||
import type { QuestionItem } from "../types/questions.js";
|
||||
|
||||
@@ -146,6 +151,54 @@ function running(session: RuntimeSession): RuntimeSession {
|
||||
return touch({ ...session, phase: "running", waitingReason: undefined });
|
||||
}
|
||||
|
||||
/** 回到流程编排:保留已验收步骤,让 design-flow 追加/改排节点 */
|
||||
function applyFlowReplan(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
reason?: string,
|
||||
): ApplyEventResult {
|
||||
const slots = { ...session.slots };
|
||||
const note = reason?.trim() ?? "";
|
||||
if (note) {
|
||||
slots["用户.下一步意向"] = note;
|
||||
slots["用户.修订说明"] = note;
|
||||
}
|
||||
delete slots[CREATION_PROPOSED_STEP_TAG];
|
||||
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
|
||||
|
||||
let artifacts = session.artifacts;
|
||||
if (session.pendingArtifactId) {
|
||||
artifacts = session.artifacts.map((a) =>
|
||||
a.id === session.pendingArtifactId
|
||||
? { ...a, status: "rejected" as const, updatedAt: nowIso() }
|
||||
: a,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
artifacts,
|
||||
pendingDecision: undefined,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
resumeContext: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [
|
||||
{ type: "unseal_creation_opening" },
|
||||
{ type: "run_worker", workerId: DESIGN_FLOW_WORKER_ID },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 游玩连跑下一执行单元;创作则交总管 */
|
||||
function afterPlayOrMainAgent(
|
||||
session: RuntimeSession,
|
||||
@@ -286,6 +339,7 @@ export function getAllowedEvents(
|
||||
case "running":
|
||||
return [
|
||||
"main_agent_decision_created",
|
||||
"creation_step_pick_awaited",
|
||||
"worker_started",
|
||||
"worker_completed",
|
||||
"worker_needs_input",
|
||||
@@ -303,6 +357,9 @@ export function getAllowedEvents(
|
||||
"user_confirmed_intake",
|
||||
"user_approved_next_step",
|
||||
"user_rejected_next_step",
|
||||
"user_requested_flow_replan",
|
||||
"user_picked_creation_step",
|
||||
"user_left_creation_step",
|
||||
"user_accepted_artifact",
|
||||
"user_rejected_artifact",
|
||||
"user_requested_revision",
|
||||
@@ -341,13 +398,34 @@ export function canApplyEvent(
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "next_intent"
|
||||
reason?.kind === "next_intent" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
case "user_confirmed_intake":
|
||||
return reason?.kind === "intake";
|
||||
case "user_approved_next_step":
|
||||
case "user_rejected_next_step":
|
||||
return reason?.kind === "approve_step";
|
||||
case "user_requested_flow_replan":
|
||||
return (
|
||||
reason?.kind === "approve_step" ||
|
||||
reason?.kind === "review_artifact" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
case "user_picked_creation_step":
|
||||
return reason?.kind === "pick_creation_step";
|
||||
case "user_left_creation_step":
|
||||
return (
|
||||
reason?.kind === "approve_step" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "revision"
|
||||
);
|
||||
case "creation_step_pick_awaited":
|
||||
return session.phase === "running";
|
||||
case "user_accepted_artifact":
|
||||
case "user_rejected_artifact":
|
||||
return reason?.kind === "review_artifact";
|
||||
@@ -361,8 +439,12 @@ export function canApplyEvent(
|
||||
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
|
||||
);
|
||||
case "main_agent_decision_created":
|
||||
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
|
||||
return session.phase === "running" || reason?.kind === "input";
|
||||
// running 时可决策;waiting_user(input/pick) 时允许总管在启动阶段插话或点选后补参确认
|
||||
return (
|
||||
session.phase === "running" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -420,13 +502,20 @@ function handleWorkerCompleted(
|
||||
|
||||
if (mode === "no_confirmation") {
|
||||
next = updateArtifact(next, artifact.id, { status: "accepted" });
|
||||
return afterPlayOrMainAgent(session, {
|
||||
const cleared = {
|
||||
...next,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
resumeContext: undefined,
|
||||
});
|
||||
};
|
||||
if (artifact.workerId === DESIGN_FLOW_WORKER_ID) {
|
||||
return {
|
||||
session: touch(running(cleared)),
|
||||
effects: [{ type: "propose_next_creation_step" }],
|
||||
};
|
||||
}
|
||||
return afterPlayOrMainAgent(session, cleared);
|
||||
}
|
||||
|
||||
// programmatic_review
|
||||
@@ -618,6 +707,10 @@ export function applyEvent(
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "pick_creation_step") {
|
||||
return applyFlowReplan(session, event, text);
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "revision") {
|
||||
const instruction =
|
||||
text.trim() || session.waitingReason.instruction?.trim() || "";
|
||||
@@ -836,16 +929,81 @@ export function applyEvent(
|
||||
}
|
||||
|
||||
case "user_rejected_next_step": {
|
||||
return applyFlowReplan(session, event, event.payload.reason);
|
||||
}
|
||||
|
||||
case "user_requested_flow_replan": {
|
||||
return applyFlowReplan(session, event, event.payload.reason);
|
||||
}
|
||||
|
||||
case "creation_step_pick_awaited": {
|
||||
return {
|
||||
session: waiting(
|
||||
touch(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
{ kind: "pick_creation_step" },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_left_creation_step": {
|
||||
const slots = { ...session.slots };
|
||||
delete slots[CREATION_PROPOSED_STEP_TAG];
|
||||
return {
|
||||
session: waiting(
|
||||
touch(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
pendingDecision: undefined,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
currentStepId: undefined,
|
||||
resumeContext: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
{ kind: "pick_creation_step" },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_picked_creation_step": {
|
||||
const stepId = event.payload.stepId.trim();
|
||||
if (!stepId) {
|
||||
return fail(session, "pick_creation_step requires stepId");
|
||||
}
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, pendingDecision: undefined },
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: DESIGN_STEP_WORKER_ID,
|
||||
pendingDecision: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1033,9 +1191,8 @@ export function applyEvent(
|
||||
if (inferLifecycleStage(session) === "play") {
|
||||
return afterPlayOrMainAgent(session, cleared);
|
||||
}
|
||||
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
|
||||
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
|
||||
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
|
||||
// 创作步验收后停在分层图上等点选(可反复能力可当场新开一条)。
|
||||
// 仅当没有任何可点节点时,propose 才会把控制权交回总管扩步。
|
||||
const proposeNext =
|
||||
artifact.workerId === "design-step" ||
|
||||
artifact.workerId === "design-flow";
|
||||
|
||||
+653
-154
@@ -17,7 +17,11 @@ import {
|
||||
} from "./phase-machine.js";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
|
||||
import {
|
||||
DEFAULT_WORKERS,
|
||||
isUnusableMainAgentOutput,
|
||||
MainAgent,
|
||||
} from "../main-agent/main-agent.js";
|
||||
import { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js";
|
||||
import {
|
||||
buildInstanceWorkerDeclaration,
|
||||
@@ -58,14 +62,32 @@ import {
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_INHERIT_EXISTING_TAG,
|
||||
CREATION_MODULE_OPENING_TAG,
|
||||
CREATION_MODULE_OPENING_STATE_TAG,
|
||||
SLOT_CREATION_MODULE_OPENING_STATE,
|
||||
loadModuleCatalog,
|
||||
isReviseStep,
|
||||
isStepAccepted,
|
||||
isInheritExistingFlag,
|
||||
mergeCreationFlowPreservingAccepted,
|
||||
nextPendingStep,
|
||||
listReadySteps,
|
||||
listSelectablePrototypes,
|
||||
listRepeatableSpawns,
|
||||
isStepReady,
|
||||
hasSelectableCreationWork,
|
||||
spawnRepeatableCreationStep,
|
||||
spawnInstanceFromPrototype,
|
||||
removeUnstartedInstance,
|
||||
isPrototypeStep,
|
||||
isPrototypeSelectable,
|
||||
isInstanceStep,
|
||||
findStepByRef,
|
||||
findModuleByName,
|
||||
parseCreationFlow,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
DESIGN_STEP_WORKER_ID,
|
||||
parseModuleOpeningState,
|
||||
patchCreationFlowStepParams,
|
||||
pickRecordedStepId,
|
||||
@@ -76,7 +98,10 @@ import {
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
stringifyModuleOpeningState,
|
||||
finalizeCreationFlow,
|
||||
type CreationFlow,
|
||||
type CreationFlowStepParams,
|
||||
type ModuleCatalog,
|
||||
type ModuleCatalogEntry,
|
||||
type ModuleParamSpec,
|
||||
} from "../skills/creation-flow.js";
|
||||
@@ -98,6 +123,7 @@ import {
|
||||
OPENING_OUTPUT_TAG,
|
||||
OPENING_SETUP_ARTIFACT_TAG,
|
||||
parseOpeningSealPayload,
|
||||
reopenCreationFlowRaw,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
SLOT_OPENING_SELECTED_INDEX,
|
||||
} from "../skills/opening-seal.js";
|
||||
@@ -326,6 +352,56 @@ export class PhaseRuntime {
|
||||
throw new Error("当前没有可重试的执行");
|
||||
}
|
||||
|
||||
/** LLM/解析失败后离开 running,避免界面一直转圈。 */
|
||||
async failRun(reason: string): Promise<RuntimeSession> {
|
||||
if (this.session.phase !== "running") return this.session;
|
||||
const result = this.commitEvent({
|
||||
type: "runtime_failed",
|
||||
payload: { reason },
|
||||
});
|
||||
await this.processEffects(result.effects);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程里留下的「执行中、没 worker」:配方已预置 DAG 时改回点选等待。
|
||||
* 读档 / 轮询时调用,避免界面一直转圈。
|
||||
*/
|
||||
recoverOrphanedRun(): boolean {
|
||||
if (this.session.phase !== "running" || this.session.waitingReason) {
|
||||
return false;
|
||||
}
|
||||
if (this.session.currentWorkerId) return false;
|
||||
if (!this.hasCreationFlow()) return false;
|
||||
debugLog("step", "恢复中断的调度,等待点选节点");
|
||||
this.clearProposedStep();
|
||||
this.session = {
|
||||
...this.session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配方已预置 DAG:跳过「先描述需求」,直接进入点选图。
|
||||
* 图上节点来自当前剧本 seed,换配方即换开局。
|
||||
*/
|
||||
async enterSeededCreationPick(): Promise<void> {
|
||||
if (!this.hasCreationFlow()) return;
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
startupCompleted: true,
|
||||
},
|
||||
};
|
||||
await this.awaitCreationStepPick();
|
||||
}
|
||||
|
||||
/** 重 roll 指定 worker(刷新 Skill 回复) */
|
||||
async rerunWorker(workerId: string): Promise<RuntimeSession> {
|
||||
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
|
||||
@@ -595,7 +671,7 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingDecision,回到总管 */
|
||||
/** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */
|
||||
async rejectStep(reason?: string): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待拒绝的决策");
|
||||
@@ -606,6 +682,180 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开当前技能步,回到分层图。
|
||||
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
|
||||
*/
|
||||
async leaveCreationStep(): Promise<RuntimeSession> {
|
||||
const waiting = this.session.waitingReason?.kind;
|
||||
const allowed =
|
||||
waiting === "worker_questions" ||
|
||||
waiting === "input" ||
|
||||
waiting === "revision" ||
|
||||
waiting === "approve_step";
|
||||
if (!allowed) {
|
||||
throw new Error("当前不能返回节点选择");
|
||||
}
|
||||
if (inferLifecycleStage(this.session) === "play") {
|
||||
throw new Error("游玩中不能返回创作节点");
|
||||
}
|
||||
const stepId = this.readPinnedStepId();
|
||||
const removed = this.unspawnCurrentInstanceIfEmpty();
|
||||
this.clearProposedStep();
|
||||
this.clearCurrentStepPointers();
|
||||
const result = await this.dispatch({
|
||||
type: "user_left_creation_step",
|
||||
payload: {},
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const stepName = removed?.name?.trim();
|
||||
this.onMessage(
|
||||
removed
|
||||
? `已取消刚增殖的「${stepName || stepId}」,回到节点选择。`
|
||||
: "已回到节点选择。",
|
||||
);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private unspawnCurrentInstanceIfEmpty(): { id: string; name: string } | null {
|
||||
const stepId = this.readPinnedStepId();
|
||||
if (!stepId) return null;
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return null;
|
||||
const step = findStepByRef(flow, stepId);
|
||||
if (!step || !isInstanceStep(step)) return null;
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
if (isStepAccepted(step, accepted)) return null;
|
||||
if (
|
||||
this.session.artifacts.some(
|
||||
(a) => a.stepId === step.id && a.status !== "rejected",
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const next = removeUnstartedInstance(flow, step.id, accepted);
|
||||
if (next.steps.length === flow.steps.length) return null;
|
||||
this.writeCreationFlow(next);
|
||||
debugLog("step", `取消未完成实例 ${step.id}`);
|
||||
return { id: step.id, name: step.name };
|
||||
}
|
||||
|
||||
private clearCurrentStepPointers(): void {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
content: "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_INHERIT_EXISTING_TAG,
|
||||
content: "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_CURRENT_STEP_TAG]: undefined,
|
||||
[CREATION_INHERIT_EXISTING_TAG]: undefined,
|
||||
[SLOT_CREATION_CURRENT_UNIT]: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
|
||||
async replanCreationFlow(reason?: string): Promise<RuntimeSession> {
|
||||
const waiting = this.session.waitingReason?.kind;
|
||||
if (waiting === "approve_step") {
|
||||
return this.rejectStep(reason);
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_requested_flow_replan",
|
||||
payload: { reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 图上点选:普通节点开干;原型槽位先增殖再进去;已完成节点可 reenter 重进 */
|
||||
async pickCreationStep(
|
||||
stepId: string,
|
||||
stepParams?: CreationFlowStepParams,
|
||||
opts?: { reenter?: boolean },
|
||||
): Promise<RuntimeSession> {
|
||||
const id = stepId.trim();
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const flow = this.readNormalizedFlow(catalog);
|
||||
const step = flow ? findStepByRef(flow, id) : null;
|
||||
if (flow && step && isPrototypeStep(step)) {
|
||||
if (opts?.reenter) {
|
||||
throw new Error("原型槽位不能回头修改,请点已完成的实例");
|
||||
}
|
||||
const spawned = spawnInstanceFromPrototype({
|
||||
flow,
|
||||
catalog,
|
||||
prototypeId: step.id,
|
||||
acceptedStepIds: this.readAcceptedStepIds(),
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
this.writeCreationFlow(spawned.flow);
|
||||
debugLog("step", `点原型 ${step.id} → ${spawned.step.id}`);
|
||||
await this.startCreationStep(spawned.step.id, { stepParams });
|
||||
return this.session;
|
||||
}
|
||||
await this.startCreationStep(id, {
|
||||
stepParams,
|
||||
reenter: opts?.reenter === true,
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 可反复能力:新编一条再进去 */
|
||||
async spawnCreationStep(moduleName: string): Promise<RuntimeSession> {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const flow = this.readNormalizedFlow(catalog);
|
||||
if (!flow) throw new Error("还没有工作流计划,无法追加节点");
|
||||
const spawned = spawnRepeatableCreationStep({
|
||||
flow,
|
||||
catalog,
|
||||
moduleName,
|
||||
acceptedStepIds: this.readAcceptedStepIds(),
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
this.writeCreationFlow(spawned.flow);
|
||||
debugLog("step", `新开 ${spawned.step.id}(${spawned.step.name})`);
|
||||
await this.startCreationStep(spawned.step.id);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private writeCreationFlow(flow: CreationFlow): void {
|
||||
const content = stringifyCreationFlow(flow);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
}
|
||||
|
||||
private readNormalizedFlow(catalog: ModuleCatalog | null): CreationFlow | null {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return null;
|
||||
const next = finalizeCreationFlow(flow, catalog);
|
||||
const changed = JSON.stringify(next.steps) !== JSON.stringify(flow.steps);
|
||||
if (changed) this.writeCreationFlow(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */
|
||||
private persistInstanceWorkerDeclaration(): void {
|
||||
const active = this.getActiveSkill();
|
||||
@@ -857,6 +1107,13 @@ export class PhaseRuntime {
|
||||
}
|
||||
break;
|
||||
case "run_worker":
|
||||
if (
|
||||
effect.workerId === DESIGN_STEP_WORKER_ID &&
|
||||
!this.hasPinnedReadyStep()
|
||||
) {
|
||||
await this.awaitCreationStepPick();
|
||||
break;
|
||||
}
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(effect);
|
||||
} else if (this.llm) {
|
||||
@@ -894,6 +1151,9 @@ export class PhaseRuntime {
|
||||
case "seal_creation_opening":
|
||||
this.sealCreationOpening();
|
||||
break;
|
||||
case "unseal_creation_opening":
|
||||
this.unsealCreationOpening();
|
||||
break;
|
||||
case "run_play_turn":
|
||||
await this.startPlayTurn();
|
||||
break;
|
||||
@@ -905,9 +1165,243 @@ export class PhaseRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
|
||||
* 用户填完「下一步想写什么」后:有可点节点则停在分层图;程序步自动开干;否则交总管扩步。
|
||||
*/
|
||||
private async proposeNextCreationStep(): Promise<void> {
|
||||
await this.awaitCreationStepPick();
|
||||
}
|
||||
|
||||
private readAcceptedStepIds(): string[] {
|
||||
return parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadActiveModuleCatalog(): Promise<ModuleCatalog | null> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return null;
|
||||
try {
|
||||
const skill = await loadSkill(active.name);
|
||||
if (!skill.skillPackRoot) return null;
|
||||
return await loadModuleCatalog(skill.skillPackRoot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPinnedStepId(): string {
|
||||
const fromCurrent = this.blackboard
|
||||
.getContentByTag(CREATION_CURRENT_STEP_TAG)
|
||||
?.trim();
|
||||
if (fromCurrent) return fromCurrent;
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
|
||||
: null) || this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
|
||||
if (!raw?.trim()) return "";
|
||||
try {
|
||||
const doc = JSON.parse(raw) as { stepId?: unknown };
|
||||
return typeof doc.stepId === "string" ? doc.stepId.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private hasPinnedReadyStep(): boolean {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return false;
|
||||
const step = findStepByRef(flow, this.readPinnedStepId());
|
||||
if (!step) return false;
|
||||
return isStepReady(flow, step, this.readAcceptedStepIds());
|
||||
}
|
||||
|
||||
private pinCreationStepSnapshot(params: {
|
||||
stepId: string;
|
||||
name: string;
|
||||
declaration: string;
|
||||
params: Record<string, unknown>;
|
||||
paramsMissing: string[];
|
||||
paramSpecs: ModuleParamSpec[];
|
||||
kind?: ModuleCatalogEntry["kind"];
|
||||
closer?: boolean;
|
||||
revise: boolean;
|
||||
revises?: string;
|
||||
}): void {
|
||||
const intent =
|
||||
typeof this.session.slots["用户.下一步意向"] === "string"
|
||||
? String(this.session.slots["用户.下一步意向"]).trim()
|
||||
: "";
|
||||
const snapshot = {
|
||||
stepId: params.stepId,
|
||||
name: params.name,
|
||||
declaration: params.declaration || undefined,
|
||||
params: params.params,
|
||||
paramsMissing: params.paramsMissing,
|
||||
paramSpecs: params.paramSpecs.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
required: Boolean(p.required),
|
||||
hint: p.hint,
|
||||
})),
|
||||
intent: intent || undefined,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
...(params.closer ? { closer: true } : {}),
|
||||
...(params.revise ? { mode: "revise" as const } : {}),
|
||||
...(params.revises ? { revises: params.revises } : {}),
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
content: params.stepId,
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_INHERIT_EXISTING_TAG,
|
||||
content: params.revise ? "1" : "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
[CREATION_CURRENT_STEP_TAG]: params.stepId,
|
||||
[CREATION_INHERIT_EXISTING_TAG]: params.revise ? "1" : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async startCreationStep(
|
||||
stepId: string,
|
||||
opts?: { stepParams?: CreationFlowStepParams; reenter?: boolean },
|
||||
): Promise<void> {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
let flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) throw new Error("还没有工作流计划");
|
||||
let step = findStepByRef(flow, stepId);
|
||||
if (!step) throw new Error(`找不到节点:${stepId}`);
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const reenter = opts?.reenter === true;
|
||||
|
||||
if (isPrototypeStep(step)) {
|
||||
if (!isPrototypeSelectable(flow, step, accepted)) {
|
||||
throw new Error(`「${step.name}」的前置还没齐,不能增殖`);
|
||||
}
|
||||
const spawned = spawnInstanceFromPrototype({
|
||||
flow,
|
||||
catalog,
|
||||
prototypeId: step.id,
|
||||
acceptedStepIds: accepted,
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
const content = stringifyCreationFlow(spawned.flow);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
flow = spawned.flow;
|
||||
step = spawned.step;
|
||||
} else if (reenter) {
|
||||
if (!isStepAccepted(step, accepted)) {
|
||||
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
|
||||
}
|
||||
} else if (!isStepReady(flow, step, accepted)) {
|
||||
throw new Error(`「${step.name}」的前置节点还没完成,不能开始`);
|
||||
}
|
||||
|
||||
const pendingModule = findModuleByName(catalog, step.name);
|
||||
const paramSpecs = pendingModule?.params ?? [];
|
||||
const auto = pendingModule?.auto === true;
|
||||
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
|
||||
const next = patchCreationFlowStepParams(flow, step.id, opts.stepParams);
|
||||
const content = stringifyCreationFlow(next);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
}
|
||||
const latest = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const pinned = latest ? findStepByRef(latest, step.id) ?? step : step;
|
||||
const params = pinned.params ? { ...pinned.params } : {};
|
||||
const paramsMissing = missingRequiredStepParams(pinned, pendingModule);
|
||||
this.pinCreationStepSnapshot({
|
||||
stepId: pinned.id,
|
||||
name: pinned.name,
|
||||
declaration: pendingModule?.declaration?.trim() || "",
|
||||
params,
|
||||
paramsMissing,
|
||||
paramSpecs,
|
||||
kind: pendingModule?.kind,
|
||||
closer: pendingModule?.closer === true,
|
||||
revise: reenter || isReviseStep(pinned),
|
||||
revises: pinned.revises,
|
||||
});
|
||||
|
||||
const waitingPick =
|
||||
this.session.waitingReason?.kind === "pick_creation_step";
|
||||
const needsParamForm = paramsMissing.length > 0 && !auto;
|
||||
if (needsParamForm) {
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
reason: `「${pinned.name}」还缺参数:${paramsMissing.join("、")}`,
|
||||
requiresApproval: true,
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (waitingPick) {
|
||||
await this.dispatch({
|
||||
type: "user_picked_creation_step",
|
||||
payload: { stepId: pinned.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
reason: reenter || isReviseStep(pinned)
|
||||
? `回头修改 · ${pinned.name}`
|
||||
: `开始:${pinned.name}`,
|
||||
requiresApproval: false,
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
|
||||
*/
|
||||
private async awaitCreationStepPick(): Promise<void> {
|
||||
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
|
||||
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
|
||||
return;
|
||||
@@ -916,121 +1410,67 @@ export class PhaseRuntime {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const accepted = parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
const pending = nextPendingStep(flow, accepted);
|
||||
if (!pending || !flow) {
|
||||
debugLog("step", "暂无下一步,交给总管");
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: undefined,
|
||||
},
|
||||
};
|
||||
if (this.mainAgent) {
|
||||
await this.runMainAgent();
|
||||
} else {
|
||||
this.onMessage(
|
||||
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
|
||||
);
|
||||
}
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
if (!flow) {
|
||||
debugLog("step", "暂无可用流程,等待点选或改编排");
|
||||
await this.enterCreationStepPickWait();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.getActiveSkill();
|
||||
let paramSpecs: ModuleParamSpec[] = [];
|
||||
let declaration = "";
|
||||
let auto = false;
|
||||
let kind: ModuleCatalogEntry["kind"];
|
||||
let pendingModule: ModuleCatalogEntry | null = null;
|
||||
if (active?.name) {
|
||||
try {
|
||||
const skill = await loadSkill(active.name);
|
||||
if (skill.skillPackRoot) {
|
||||
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
||||
const mod = catalog?.modules.find((m) => m.name === pending.name);
|
||||
paramSpecs = mod?.params ?? [];
|
||||
declaration = mod?.declaration?.trim() || "";
|
||||
auto = mod?.auto === true;
|
||||
kind = mod?.kind;
|
||||
pendingModule = mod ?? null;
|
||||
}
|
||||
} catch {
|
||||
/* catalog optional */
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const ready = listReadySteps(flow, accepted);
|
||||
const autoReady = ready.filter((step) => {
|
||||
const mod = findModuleByName(catalog, step.name);
|
||||
return (
|
||||
mod?.auto === true &&
|
||||
missingRequiredStepParams(step, mod).length === 0
|
||||
);
|
||||
});
|
||||
if (autoReady.length > 0 && autoReady.length === ready.length) {
|
||||
const protoReady = listSelectablePrototypes(flow, accepted).length > 0;
|
||||
const spawnReady = catalog
|
||||
? listRepeatableSpawns(flow, catalog, accepted).some((s) => s.ready)
|
||||
: false;
|
||||
if (!protoReady && !spawnReady) {
|
||||
debugLog("step", `程序步直接开始 ${autoReady[0]!.id}`);
|
||||
await this.startCreationStep(autoReady[0]!.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
debugLog(
|
||||
"step",
|
||||
hasSelectableCreationWork(flow, catalog, accepted)
|
||||
? "等待点选创作节点"
|
||||
: "图上暂无已就绪节点,点节点进入或在底栏写意见改编排",
|
||||
);
|
||||
await this.enterCreationStepPickWait();
|
||||
}
|
||||
|
||||
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
|
||||
|
||||
const params = pending.params ? { ...pending.params } : {};
|
||||
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
|
||||
|
||||
const intent =
|
||||
typeof this.session.slots["用户.下一步意向"] === "string"
|
||||
? String(this.session.slots["用户.下一步意向"]).trim()
|
||||
: "";
|
||||
const paramHint =
|
||||
Object.keys(params).length > 0
|
||||
? `;参数 ${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" · ")}`
|
||||
: paramsMissing.length
|
||||
? `;待钉参数:${paramsMissing.join("、")}`
|
||||
: "";
|
||||
const revise = isReviseStep(pending);
|
||||
const reason = [
|
||||
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`,
|
||||
declaration ? `—— ${declaration}` : "",
|
||||
paramHint,
|
||||
intent ? `(你的意向:${intent})` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
const snapshot = {
|
||||
stepId: pending.id,
|
||||
name: pending.name,
|
||||
declaration: declaration || undefined,
|
||||
params,
|
||||
paramsMissing,
|
||||
paramSpecs: paramSpecs.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
required: Boolean(p.required),
|
||||
hint: p.hint,
|
||||
})),
|
||||
intent: intent || undefined,
|
||||
...(kind ? { kind } : {}),
|
||||
...(revise ? { mode: "revise" as const } : {}),
|
||||
...(pending.revises ? { revises: pending.revises } : {}),
|
||||
private async enterCreationStepPickWait(): Promise<void> {
|
||||
this.clearProposedStep();
|
||||
if (this.session.waitingReason?.kind === "pick_creation_step") return;
|
||||
if (this.session.phase === "running") {
|
||||
await this.dispatch({ type: "creation_step_pick_awaited", payload: {} });
|
||||
return;
|
||||
}
|
||||
this.session = {
|
||||
...this.session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
|
||||
private clearProposedStep(): void {
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
[CREATION_PROPOSED_STEP_TAG]: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: "design-step",
|
||||
reason,
|
||||
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
|
||||
requiresApproval: !(auto && paramsMissing.length === 0),
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1108,6 +1548,24 @@ export class PhaseRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
|
||||
private unsealCreationOpening(): void {
|
||||
const reopened = reopenCreationFlowRaw(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (reopened) {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: reopened,
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
const slots = { ...this.session.slots };
|
||||
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
|
||||
if (reopened) slots[CREATION_FLOW_TAG] = reopened;
|
||||
this.session = { ...this.session, slots };
|
||||
}
|
||||
|
||||
private applyProposedStepParams(params: CreationFlowStepParams): void {
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
@@ -1807,6 +2265,10 @@ export class PhaseRuntime {
|
||||
flowRaw,
|
||||
currentStepName,
|
||||
acceptedStepNames: accepted,
|
||||
inheritExisting: isInheritExistingFlag(
|
||||
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
|
||||
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
|
||||
),
|
||||
});
|
||||
if (!binding?.opening?.trim()) return false;
|
||||
|
||||
@@ -1952,7 +2414,9 @@ export class PhaseRuntime {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const pending = nextPendingStep(flow, accepted);
|
||||
const pinnedId = this.readPinnedStepId();
|
||||
const pinned = flow && pinnedId ? findStepByRef(flow, pinnedId) : null;
|
||||
const pending = pinned ?? nextPendingStep(flow, accepted);
|
||||
current = pending ? stepUnitId(pending) : current || "(无待执行步骤)";
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
@@ -2209,65 +2673,100 @@ export class PhaseRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private hasCreationFlow(): boolean {
|
||||
return Boolean(
|
||||
parseCreationFlow(this.blackboard.getContentByTag(CREATION_FLOW_TAG)),
|
||||
);
|
||||
}
|
||||
|
||||
private async commitMainAgentDecision(
|
||||
decision: MainAgentDecision,
|
||||
): Promise<void> {
|
||||
debugLog(
|
||||
"step",
|
||||
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
|
||||
);
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
const result = this.commitEvent({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
if (result.error) this.onMessage(result.error);
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (!this.mainAgent || this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasCreationFlow()) {
|
||||
debugLog("step", "已有创作流程,等待点选节点");
|
||||
await this.awaitCreationStepPick();
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("step", "总管开始");
|
||||
const availableWorkers = await this.resolveAvailableWorkers();
|
||||
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
onThinkingDelta: (delta) => {
|
||||
this.onAgentThinkingDelta?.(delta);
|
||||
},
|
||||
onThinkingDone: (text) => {
|
||||
this.onAgentThinkingDone?.(text);
|
||||
this.onMessage(`[总管 思考]\n\n${text}`);
|
||||
try {
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
onThinkingDelta: (delta) => {
|
||||
this.onAgentThinkingDelta?.(delta);
|
||||
},
|
||||
onThinkingDone: (text) => {
|
||||
this.onAgentThinkingDone?.(text);
|
||||
this.onMessage(`[总管 思考]\n\n${text}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
debugLog(
|
||||
"step",
|
||||
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
|
||||
);
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = this.commitEvent({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this.onMessage(result.error);
|
||||
);
|
||||
await this.commitMainAgentDecision(decision);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
if (this.hasCreationFlow()) {
|
||||
debugLog("step", "总管无可用决策,改为等待点选");
|
||||
await this.awaitCreationStepPick();
|
||||
return;
|
||||
}
|
||||
if (isUnusableMainAgentOutput(err)) {
|
||||
debugLog("step", "总管未给出工具调用,改为启动 design-flow");
|
||||
await this.commitMainAgentDecision(
|
||||
createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_FLOW_WORKER_ID,
|
||||
reason: "尚无创作流程,开始编排近期起点",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugLog("step", `总管失败 ${reason.slice(0, 120)}`);
|
||||
await this.failRun(reason);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
|
||||
|
||||
@@ -435,6 +435,15 @@ export function buildFocus(
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "pick_creation_step") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "点选要做的节点",
|
||||
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "next_intent") {
|
||||
return {
|
||||
actorType: "user",
|
||||
@@ -471,6 +480,14 @@ export function buildFocus(
|
||||
|
||||
if (reason?.kind === "review_artifact") {
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
if (art?.workerId === "design-flow") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "点选要做的节点",
|
||||
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
|
||||
};
|
||||
}
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
outputTags: art?.outputTags,
|
||||
|
||||
@@ -123,6 +123,17 @@ export function formatAgentDisplayTitle(detail?: string): string {
|
||||
return "编排器";
|
||||
}
|
||||
|
||||
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
|
||||
export function isFlowPlanReview(view: {
|
||||
waitingReason?: { kind?: string } | null;
|
||||
reviewArtifact?: { workerId?: string | null } | null;
|
||||
} | null | undefined): boolean {
|
||||
return (
|
||||
view?.waitingReason?.kind === "review_artifact" &&
|
||||
view?.reviewArtifact?.workerId === "design-flow"
|
||||
);
|
||||
}
|
||||
|
||||
/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
|
||||
export type ReviewComposerCopy = {
|
||||
/** 底栏短芯片 */
|
||||
@@ -179,8 +190,8 @@ export function reviewComposerCopy(
|
||||
taskLabel: "开场",
|
||||
taskTitle: "选定开场白",
|
||||
hint: hasQuestions
|
||||
? "同意就选定此开场;要改就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改就写意见。",
|
||||
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改开场白就写意见。",
|
||||
submitLabel: "按意见修改",
|
||||
acceptLabel: "选定此开场",
|
||||
placeholder: "修改意见…",
|
||||
|
||||
+324
-32
@@ -81,8 +81,10 @@ import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
|
||||
import {
|
||||
CREATION_ACCEPTED_CONTENT_TAG,
|
||||
CREATION_ACCEPTED_UNITS_TAG,
|
||||
isDesignDiskWorker,
|
||||
parseAcceptedContentStore,
|
||||
parseAcceptedUnits,
|
||||
SLOT_CREATION_ACCEPTED_UNITS,
|
||||
} from "../skills/creation-units.js";
|
||||
@@ -104,18 +106,26 @@ import {
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_SELECTED_RECIPE_TAG,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
creationFlowFromRecipeSeed,
|
||||
findRecipeCatalogEntry,
|
||||
formatCreationFlowForUser,
|
||||
findModuleByName,
|
||||
findStepByRef,
|
||||
type FlowStepTitleHint,
|
||||
isProgressPointerTag,
|
||||
loadRecipeCatalog,
|
||||
loadRecipeDetail,
|
||||
loadModuleCatalog,
|
||||
parseCreationFlow,
|
||||
parseSelectedRecipeRef,
|
||||
resolveDesignStepBinding,
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
stripReviseSteps,
|
||||
type CreationFlow,
|
||||
type CreationFlowUserView,
|
||||
type ModuleCatalog,
|
||||
type RecipeCatalogEntry,
|
||||
} from "../skills/creation-flow.js";
|
||||
import { loadSkill } from "../skills/loader.js";
|
||||
@@ -260,6 +270,8 @@ export type SessionAction =
|
||||
| { type: "approve"; label: string }
|
||||
| { type: "accept"; label: string }
|
||||
| { type: "reject"; label: string }
|
||||
| { type: "replan"; label: string }
|
||||
| { type: "leave_step"; label: string }
|
||||
| { type: "run_outline"; label: string }
|
||||
| { type: "finish"; label: string };
|
||||
|
||||
@@ -297,6 +309,8 @@ export type ProposedNextStepView = {
|
||||
/** 步骤怎么跑:revise = 回头修改既有产物 */
|
||||
mode?: "fresh" | "revise";
|
||||
revises?: string;
|
||||
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
|
||||
closer?: boolean;
|
||||
};
|
||||
|
||||
export type ReviewArtifactView = {
|
||||
@@ -325,6 +339,8 @@ type ManagedSession = {
|
||||
branchState: MessageBranchState;
|
||||
/** 当前能力包可选初始配方(用户手动选) */
|
||||
recipeOptions?: RecipeCatalogEntry[];
|
||||
/** 模块目录(分层图 / 可反复增殖) */
|
||||
moduleCatalog?: ModuleCatalog | null;
|
||||
/** 开局模块 opening,缓存给意图页 */
|
||||
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||
/** 当前 LLM 请求的取消器(停止并重试) */
|
||||
@@ -540,6 +556,7 @@ export class SessionManager {
|
||||
if (bookId) {
|
||||
this.activeBookSessions.set(bookId, id);
|
||||
appendBookSession(bookId, id);
|
||||
this.persist(managed);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
@@ -557,7 +574,9 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
get(id: string): SessionView | null {
|
||||
if (!this.sessions.has(id)) return null;
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return null;
|
||||
if (s.runtime.recoverOrphanedRun()) this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
@@ -825,7 +844,8 @@ export class SessionManager {
|
||||
kind !== "review_artifact" &&
|
||||
kind !== "approve_step" &&
|
||||
kind !== "revision" &&
|
||||
kind !== "next_intent"
|
||||
kind !== "next_intent" &&
|
||||
kind !== "pick_creation_step"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1079,8 +1099,7 @@ export class SessionManager {
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 发送消息失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
await this.recoverFailedRun(s, detail);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
@@ -1315,8 +1334,7 @@ export class SessionManager {
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 停止并重试失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
await this.recoverFailedRun(s, detail);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
@@ -1462,7 +1480,7 @@ export class SessionManager {
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
if (waiting?.kind === "approve_step") {
|
||||
await s.runtime.rejectStep(reason ?? "用户暂不执行");
|
||||
await s.runtime.leaveCreationStep();
|
||||
} else if (waiting?.kind === "review_artifact") {
|
||||
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
|
||||
} else {
|
||||
@@ -1476,9 +1494,174 @@ export class SessionManager {
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 离开当前技能步,回到分层图点选 */
|
||||
async leaveStep(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, () =>
|
||||
s.runtime.leaveCreationStep(),
|
||||
);
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 返回节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
|
||||
async replan(id: string, reason?: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
const note = reason?.trim() || "回到流程编排,追加或改排节点。";
|
||||
this.clearAgentThinking(id);
|
||||
recordPreMessageCheckpoint(
|
||||
s.branchState,
|
||||
s.messages.length,
|
||||
this.captureCheckpoint(s),
|
||||
);
|
||||
s.messages.push(this.msg("user", note));
|
||||
this.syncCreationDialogue(s);
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, () =>
|
||||
s.runtime.replanCreationFlow(note),
|
||||
);
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 再编排失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async pickStep(
|
||||
id: string,
|
||||
stepId: string,
|
||||
stepParams?: Record<string, unknown>,
|
||||
reenter?: boolean,
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
await this.confirmPendingFlowPlan(s);
|
||||
const waiting = s.runtime.getSession().waitingReason?.kind;
|
||||
if (waiting !== "pick_creation_step") {
|
||||
throw new Error("当前不是点选节点的时机");
|
||||
}
|
||||
await s.runtime.pickCreationStep(stepId, stepParams, {
|
||||
reenter: reenter === true,
|
||||
});
|
||||
});
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 点选节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async getStepArtifact(
|
||||
id: string,
|
||||
stepId: string,
|
||||
): Promise<{
|
||||
stepId: string;
|
||||
name: string;
|
||||
tag: string | null;
|
||||
content: string;
|
||||
}> {
|
||||
const s = this.require(id);
|
||||
const catalog = s.moduleCatalog;
|
||||
const flow = parseCreationFlow(
|
||||
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) throw new Error("还没有工作流计划");
|
||||
const cleaned = stripReviseSteps(flow);
|
||||
const step = findStepByRef(cleaned, stepId.trim());
|
||||
if (!step) throw new Error(`找不到节点:${stepId}`);
|
||||
const mod = findModuleByName(catalog, step.name);
|
||||
const tag = mod?.artifact?.trim() || null;
|
||||
const content = tag
|
||||
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
|
||||
: "";
|
||||
return { stepId: step.id, name: step.name, tag, content };
|
||||
}
|
||||
|
||||
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
await this.confirmPendingFlowPlan(s);
|
||||
const waiting = s.runtime.getSession().waitingReason?.kind;
|
||||
if (waiting !== "pick_creation_step") {
|
||||
throw new Error("当前不是追加节点的时机");
|
||||
}
|
||||
await s.runtime.spawnCreationStep(moduleName);
|
||||
});
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 追加节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
|
||||
private canOfferLeaveCreationStep(
|
||||
s: ManagedSession,
|
||||
reason: WaitingReason | undefined,
|
||||
): boolean {
|
||||
const session = s.runtime.getSession();
|
||||
if (
|
||||
isPlayLayerActive(session.slots) ||
|
||||
inferLifecycleStage(session) === "play"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const kind = reason?.kind;
|
||||
if (
|
||||
kind !== "worker_questions" &&
|
||||
kind !== "input" &&
|
||||
kind !== "revision"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const stepId =
|
||||
s.runtime.getBlackboard().getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() ||
|
||||
session.currentStepId?.trim() ||
|
||||
"";
|
||||
return Boolean(stepId);
|
||||
}
|
||||
|
||||
/** 流程编排还停在核对态时,点节点先收下计划再进入 */
|
||||
private async confirmPendingFlowPlan(s: ManagedSession): Promise<void> {
|
||||
const session = s.runtime.getSession();
|
||||
if (session.waitingReason?.kind !== "review_artifact") return;
|
||||
const art = session.pendingArtifactId
|
||||
? session.artifacts.find((a) => a.id === session.pendingArtifactId)
|
||||
: undefined;
|
||||
if (art?.workerId !== DESIGN_FLOW_WORKER_ID) {
|
||||
throw new Error("当前不是点选节点的时机");
|
||||
}
|
||||
await s.runtime.acceptArtifact();
|
||||
}
|
||||
|
||||
async runOutline(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
@@ -1586,6 +1769,7 @@ export class SessionManager {
|
||||
this.activeBookSessions.set(params.bookId, params.sessionId);
|
||||
appendBookSession(params.bookId, params.sessionId);
|
||||
await this.refreshRecipeOptions(managedRef);
|
||||
runtime.recoverOrphanedRun();
|
||||
this.persist(managedRef);
|
||||
await this.maybeKickAgentAfterDemand(runtime);
|
||||
|
||||
@@ -1891,6 +2075,20 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行失败后离开 running,避免界面一直转圈。 */
|
||||
private async recoverFailedRun(
|
||||
s: ManagedSession,
|
||||
detail: string,
|
||||
): Promise<void> {
|
||||
const formatted = formatRuntimeError(detail);
|
||||
if (s.runtime.getSession().phase === "running") {
|
||||
await s.runtime.failRun(formatted);
|
||||
} else {
|
||||
s.messages.push(this.msg("system", formatted));
|
||||
}
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
|
||||
private enrichDisplayMessages(
|
||||
session: RuntimeSession,
|
||||
messages: ChatMessage[],
|
||||
@@ -2202,11 +2400,21 @@ export class SessionManager {
|
||||
} else {
|
||||
actions.push({ type: "approve", label: "确认执行" });
|
||||
}
|
||||
actions.push({ type: "reject", label: "暂不执行" });
|
||||
actions.push({ type: "reject", label: "返回节点" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "说明意见",
|
||||
placeholder: "改排、回头改某步,或说明不要这一步…",
|
||||
placeholder: "改排、补节点(如 NPC)、回头改某步,或说明不要这一步…",
|
||||
});
|
||||
} else if (reason?.kind === "pick_creation_step") {
|
||||
hints.push(
|
||||
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
|
||||
);
|
||||
actions.push({ type: "replan", label: "发送" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
|
||||
});
|
||||
} else if (reason?.kind === "next_intent") {
|
||||
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
|
||||
@@ -2220,15 +2428,30 @@ export class SessionManager {
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
});
|
||||
actions.push({ type: "accept", label: copy.acceptLabel });
|
||||
actions.push({ type: "reject", label: "不接受" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: copy.submitLabel,
|
||||
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
|
||||
});
|
||||
if (art?.workerId === DESIGN_FLOW_WORKER_ID) {
|
||||
hints.push(
|
||||
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
|
||||
);
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
|
||||
});
|
||||
} else {
|
||||
actions.push({ type: "accept", label: copy.acceptLabel });
|
||||
actions.push({ type: "reject", label: "不接受" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: copy.submitLabel,
|
||||
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
|
||||
});
|
||||
}
|
||||
if (reason.questions?.length) {
|
||||
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
|
||||
hints.push(
|
||||
art?.workerId === DESIGN_FLOW_WORKER_ID
|
||||
? "下方追问可选;点节点进入即收起"
|
||||
: `下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`,
|
||||
);
|
||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||
}
|
||||
@@ -2281,6 +2504,10 @@ export class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canOfferLeaveCreationStep(s, reason)) {
|
||||
actions.push({ type: "leave_step", label: "返回节点" });
|
||||
}
|
||||
|
||||
if (s.bookId) {
|
||||
this.persist(s);
|
||||
}
|
||||
@@ -2330,7 +2557,12 @@ export class SessionManager {
|
||||
effectiveStartupMode(activeSnap) === "agent-first"
|
||||
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
|
||||
: undefined,
|
||||
reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()),
|
||||
reviewArtifact: buildReviewArtifactView(
|
||||
session,
|
||||
messages,
|
||||
s.runtime.getBlackboard(),
|
||||
s.moduleCatalog,
|
||||
),
|
||||
workerSetView: buildWorkerSetUserView(
|
||||
s.runtime.getBlackboard(),
|
||||
s.runtime.getSession(),
|
||||
@@ -2338,6 +2570,7 @@ export class SessionManager {
|
||||
creationFlowView: buildCreationFlowUserView(
|
||||
s.runtime.getBlackboard(),
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
s.moduleCatalog,
|
||||
),
|
||||
proposedNextStep:
|
||||
reason?.kind === "approve_step"
|
||||
@@ -2448,6 +2681,7 @@ export class SessionManager {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (!skillName) {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
@@ -2455,13 +2689,19 @@ export class SessionManager {
|
||||
const skill = await loadSkill(skillName);
|
||||
if (!skill.skillPackRoot) {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
|
||||
const [catalog, modules] = await Promise.all([
|
||||
loadRecipeCatalog(skill.skillPackRoot),
|
||||
loadModuleCatalog(skill.skillPackRoot),
|
||||
]);
|
||||
s.recipeOptions = catalog?.recipes ?? [];
|
||||
s.moduleCatalog = modules;
|
||||
} catch {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
}
|
||||
await this.ensureRecipeSeededFlow(s);
|
||||
await this.refreshOpeningGuide(s);
|
||||
@@ -2489,11 +2729,7 @@ export class SessionManager {
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (!seeded) return;
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
await this.commitSeededCreationFlow(s, seeded);
|
||||
} catch (err) {
|
||||
console.error("[会话] 配方预置失败", err);
|
||||
}
|
||||
@@ -2553,6 +2789,7 @@ export class SessionManager {
|
||||
s: ManagedSession,
|
||||
reason: WaitingReason | undefined,
|
||||
): { stepId: string; stepName: string; text: string } | null {
|
||||
if (reason?.kind === "pick_creation_step") return null;
|
||||
if (reason?.kind === "worker_questions") {
|
||||
const qs = normalizeQuestions(reason.questions);
|
||||
if (isModuleOpeningQuestions(qs)) {
|
||||
@@ -2584,6 +2821,44 @@ export class SessionManager {
|
||||
return s.openingGuide ?? null;
|
||||
}
|
||||
|
||||
/** 写入剧本 seed,并立刻进入点选图(开局节点跟配方走)。 */
|
||||
private async commitSeededCreationFlow(
|
||||
s: ManagedSession,
|
||||
seeded: CreationFlow,
|
||||
): Promise<void> {
|
||||
this.writeSeededCreationFlow(s, seeded);
|
||||
await s.runtime.enterSeededCreationPick();
|
||||
}
|
||||
|
||||
/** 配方预置 DAG:写入黑板并留下可回退的编排检查点(不必先跑 LLM) */
|
||||
private writeSeededCreationFlow(s: ManagedSession, seeded: CreationFlow): void {
|
||||
s.runtime.getBlackboard().write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
const alreadyRecorded = s.messages.some(
|
||||
(m) =>
|
||||
m.kind === "worker_output" &&
|
||||
m.actor === "design-flow" &&
|
||||
(m.text ?? "").includes("配方预置近期起点"),
|
||||
);
|
||||
if (alreadyRecorded) return;
|
||||
const names = seeded.steps.map((step) => step.name).join(" → ");
|
||||
recordPreMessageCheckpoint(
|
||||
s.branchState,
|
||||
s.messages.length,
|
||||
this.captureCheckpoint(s),
|
||||
);
|
||||
s.messages.push(
|
||||
this.msg(
|
||||
"system",
|
||||
`[Worker] design-flow 已完成\n\n配方预置近期起点${names ? `:${names}` : ""}。\n这是可回退的编排检查点;之后可再编排追加节点(如生成规则 / 具体实例)。`,
|
||||
),
|
||||
);
|
||||
this.syncCreationDialogue(s);
|
||||
}
|
||||
|
||||
private async applySelectedRecipe(
|
||||
s: ManagedSession,
|
||||
recipeId: string,
|
||||
@@ -2606,7 +2881,7 @@ export class SessionManager {
|
||||
source: "user",
|
||||
});
|
||||
|
||||
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow)
|
||||
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
|
||||
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!existingFlow) {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
@@ -2618,11 +2893,7 @@ export class SessionManager {
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (seeded) {
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
await this.commitSeededCreationFlow(s, seeded);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -2652,6 +2923,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
|
||||
function buildCreationFlowUserView(
|
||||
blackboard: Blackboard,
|
||||
acceptedRaw?: unknown,
|
||||
catalog?: ModuleCatalog | null,
|
||||
): CreationFlowUserView | undefined {
|
||||
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!raw) return undefined;
|
||||
@@ -2662,7 +2934,24 @@ function buildCreationFlowUserView(
|
||||
const accepted = parseAcceptedUnits(
|
||||
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
return formatCreationFlowForUser(flow, undefined, accepted);
|
||||
const titleHints: Record<string, FlowStepTitleHint> = {};
|
||||
const store = parseAcceptedContentStore(
|
||||
blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
|
||||
);
|
||||
for (const [id, entry] of Object.entries(store.units)) {
|
||||
if (!entry.summary && entry.content == null) continue;
|
||||
titleHints[id] = {
|
||||
...(entry.summary ? { summary: entry.summary } : {}),
|
||||
...(entry.content != null ? { content: entry.content } : {}),
|
||||
};
|
||||
}
|
||||
return formatCreationFlowForUser(
|
||||
flow,
|
||||
catalog,
|
||||
accepted,
|
||||
collectFilledTags(blackboard),
|
||||
titleHints,
|
||||
);
|
||||
}
|
||||
|
||||
function readProposedNextStep(
|
||||
@@ -2708,6 +2997,7 @@ function readProposedNextStep(
|
||||
intent: typeof doc.intent === "string" ? doc.intent : undefined,
|
||||
mode: doc.mode === "revise" ? "revise" : undefined,
|
||||
revises: typeof doc.revises === "string" ? doc.revises : undefined,
|
||||
closer: doc.closer === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -2767,6 +3057,7 @@ function buildReviewArtifactView(
|
||||
session: RuntimeSession,
|
||||
messages: ChatMessage[],
|
||||
blackboard: Blackboard,
|
||||
catalog?: ModuleCatalog | null,
|
||||
): ReviewArtifactView | undefined {
|
||||
if (session.waitingReason?.kind !== "review_artifact") return undefined;
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
@@ -2869,6 +3160,7 @@ function buildReviewArtifactView(
|
||||
? buildCreationFlowUserView(
|
||||
blackboard,
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
catalog,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -2896,7 +3188,7 @@ function formatRuntimeError(detail: string): string {
|
||||
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
|
||||
return (
|
||||
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
|
||||
"或点击「测试连接」验证。"
|
||||
"或点击「探测」验证。"
|
||||
);
|
||||
}
|
||||
if (detail.startsWith("LLM request failed")) {
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
deleteApiProfile,
|
||||
getApiProfile,
|
||||
listApiProfiles,
|
||||
testApiProfile,
|
||||
probeAndSaveProfileCapabilities,
|
||||
updateApiProfile,
|
||||
} from "../config/api-profiles.js";
|
||||
import {
|
||||
deliveryModeLabel,
|
||||
resolveStructuredDeliveryMode,
|
||||
} from "../llm/capabilities.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
@@ -120,12 +124,14 @@ export async function handleSettingsApi(
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
const profile = createApiProfile({
|
||||
name: body.name ?? "新配置",
|
||||
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
|
||||
apiKey: body.apiKey ?? "",
|
||||
model: body.model ?? "deepseek-v4-pro",
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
});
|
||||
const activate = (body as { activate?: boolean }).activate === true;
|
||||
if (activate) {
|
||||
@@ -136,19 +142,34 @@ export async function handleSettingsApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
|
||||
const profileMatch = pathname.match(
|
||||
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
|
||||
);
|
||||
if (profileMatch) {
|
||||
const id = decodeURIComponent(profileMatch[1]);
|
||||
const isTest = profileMatch[2] === "/test";
|
||||
const suffix = profileMatch[2];
|
||||
|
||||
if (isTest && req.method === "POST") {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) {
|
||||
json(res, 404, { error: "配置不存在" });
|
||||
return true;
|
||||
if (suffix === "/probe-capabilities" && req.method === "POST") {
|
||||
try {
|
||||
const profile = await probeAndSaveProfileCapabilities(id);
|
||||
const mode = resolveStructuredDeliveryMode(profile.capabilities);
|
||||
const settings = loadAppSettings();
|
||||
const reloadedSessions =
|
||||
settings.activeProfileId === id
|
||||
? sessionManager.reloadAllLlms()
|
||||
: 0;
|
||||
json(res, 200, {
|
||||
ok: profile.capabilities?.chat === "ok",
|
||||
profile,
|
||||
deliveryMode: mode,
|
||||
deliveryModeLabel: deliveryModeLabel(mode),
|
||||
reloadedSessions,
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "能力探测失败",
|
||||
});
|
||||
}
|
||||
const result = await testApiProfile(profile);
|
||||
json(res, 200, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -168,10 +189,16 @@ export async function handleSettingsApi(
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
try {
|
||||
const profile = updateApiProfile(id, body);
|
||||
json(res, 200, { profile });
|
||||
const settings = loadAppSettings();
|
||||
const reloadedSessions =
|
||||
settings.activeProfileId === id
|
||||
? sessionManager.reloadAllLlms()
|
||||
: 0;
|
||||
json(res, 200, { profile, reloadedSessions });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : "更新失败",
|
||||
|
||||
@@ -123,6 +123,18 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && sub === "/step-artifact") {
|
||||
try {
|
||||
const stepId = url.searchParams.get("stepId") ?? "";
|
||||
const data = await sessionManager.getStepArtifact(sessionId, stepId);
|
||||
json(res, 200, data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "读取失败";
|
||||
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/messages") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
text?: string;
|
||||
@@ -445,6 +457,10 @@ const server = createServer(async (req, res) => {
|
||||
action?: string;
|
||||
stepParams?: Record<string, unknown>;
|
||||
openingIndex?: number;
|
||||
reason?: string;
|
||||
stepId?: string;
|
||||
moduleName?: string;
|
||||
reenter?: boolean;
|
||||
};
|
||||
let view;
|
||||
try {
|
||||
@@ -474,6 +490,31 @@ const server = createServer(async (req, res) => {
|
||||
case "reject":
|
||||
view = await sessionManager.reject(sessionId);
|
||||
break;
|
||||
case "replan":
|
||||
view = await sessionManager.replan(
|
||||
sessionId,
|
||||
typeof body.reason === "string" ? body.reason : undefined,
|
||||
);
|
||||
break;
|
||||
case "leave_step":
|
||||
view = await sessionManager.leaveStep(sessionId);
|
||||
break;
|
||||
case "pick_step":
|
||||
view = await sessionManager.pickStep(
|
||||
sessionId,
|
||||
typeof body.stepId === "string" ? body.stepId : "",
|
||||
body.stepParams && typeof body.stepParams === "object"
|
||||
? body.stepParams
|
||||
: undefined,
|
||||
body.reenter === true,
|
||||
);
|
||||
break;
|
||||
case "spawn_step":
|
||||
view = await sessionManager.spawnStep(
|
||||
sessionId,
|
||||
typeof body.moduleName === "string" ? body.moduleName : "",
|
||||
);
|
||||
break;
|
||||
case "run_outline":
|
||||
view = await sessionManager.runOutline(sessionId);
|
||||
break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 上下文片段产物(context-fragment.v1)解析与展示辅助。
|
||||
* 规范:docs/context-fragment-design.md
|
||||
*/
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
import {
|
||||
normalizeQuestions,
|
||||
type QuestionItem,
|
||||
@@ -75,14 +75,9 @@ function normalizeStability(v: unknown): ContextFragmentStability | undefined {
|
||||
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
@@ -278,14 +273,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
|
||||
} {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return { questions: [], assessment: "" };
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
|
||||
+1029
-76
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,8 @@ type WorkerTemplateDoc = {
|
||||
|
||||
/**
|
||||
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
|
||||
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
|
||||
* - design-flow → no_confirmation(图上点节点即确认;底栏输入即改编排)
|
||||
* - design-step / opening-generator 磁盘创作 worker → 始终 user_confirmed
|
||||
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
|
||||
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
|
||||
* - 未声明 acceptance:创作默认确认
|
||||
@@ -55,6 +56,7 @@ export function resolveAcceptanceModeForWorker(params: {
|
||||
workerId: string;
|
||||
}): AcceptanceMode {
|
||||
const workerId = params.workerId.trim();
|
||||
if (workerId === "design-flow") return "no_confirmation";
|
||||
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
|
||||
return "user_confirmed";
|
||||
}
|
||||
@@ -95,7 +97,7 @@ export async function resolveRunnableWorker(params: {
|
||||
const acceptedRaw =
|
||||
params.session.slots?.creationAcceptedUnits ??
|
||||
params.blackboard.getContentByTag("创作.已验收单位");
|
||||
const { parseAcceptedSteps } = await import("./creation-flow.js");
|
||||
const { parseAcceptedSteps, isInheritExistingFlag } = await import("./creation-flow.js");
|
||||
const withCtx = await loadWorkerSkillWithContext(
|
||||
params.skillPackName,
|
||||
params.workerId,
|
||||
@@ -106,6 +108,9 @@ export async function resolveRunnableWorker(params: {
|
||||
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
|
||||
inheritExisting: isInheritExistingFlag(
|
||||
params.blackboard.getContentByTag("创作.继承修改"),
|
||||
),
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -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 ?? ""),
|
||||
};
|
||||
}
|
||||
@@ -475,6 +475,8 @@ export async function loadWorkerSkillWithContext(
|
||||
selectedRecipeRef?: string | null;
|
||||
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
|
||||
filledArtifactTags?: readonly string[];
|
||||
/** 用户点已完成节点重进 */
|
||||
inheritExisting?: boolean;
|
||||
},
|
||||
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
||||
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
||||
@@ -526,6 +528,7 @@ export async function loadWorkerSkillWithContext(
|
||||
currentStepName: opts?.currentStepName,
|
||||
acceptedStepNames: opts?.acceptedStepNames,
|
||||
skillsRoot,
|
||||
inheritExisting: opts?.inheritExisting,
|
||||
});
|
||||
if (binding) {
|
||||
const inheritTag = binding.inheritTag?.trim() || "";
|
||||
@@ -536,8 +539,11 @@ export async function loadWorkerSkillWithContext(
|
||||
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
|
||||
: "";
|
||||
const paramsBlock = isPriorArtifactModule(binding.module)
|
||||
? formatPriorArtifactContext(binding.step.params)
|
||||
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
|
||||
? formatPriorArtifactContext(
|
||||
binding.step.params,
|
||||
binding.prototypeSuggestion,
|
||||
)
|
||||
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问编排层已定的参数)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
|
||||
let rosterBlock = "";
|
||||
const depProjection =
|
||||
binding.module.id === "context-order" ? "index" : undefined;
|
||||
|
||||
@@ -198,3 +198,11 @@ export function closeCreationFlowRaw(raw: string | null | undefined): string | n
|
||||
if (flow.status === "closed") return stringifyCreationFlow(flow);
|
||||
return stringifyCreationFlow({ ...flow, status: "closed" });
|
||||
}
|
||||
|
||||
/** 再编排:收口前补节点时把 DAG 改回 open。 */
|
||||
export function reopenCreationFlowRaw(raw: string | null | undefined): string | null {
|
||||
const flow = parseCreationFlow(raw);
|
||||
if (!flow) return raw?.trim() ? raw : null;
|
||||
if (flow.status !== "closed") return stringifyCreationFlow(flow);
|
||||
return stringifyCreationFlow({ ...flow, status: "open" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
import {
|
||||
inferPlaySlotsFromWorkers,
|
||||
mergeWorkersWithPlaySlots,
|
||||
@@ -400,6 +401,10 @@ export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | n
|
||||
|
||||
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
|
||||
export function extractJsonObjectText(raw: string): string | null {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed != null && typeof parsed === "object") {
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (text.startsWith("{")) {
|
||||
|
||||
@@ -72,9 +72,16 @@ export function buildContextTrace(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 编排检查点:不随「只留最新 N 条」丢掉,否则开场白阶段无法回看/回退到流程编排 */
|
||||
export function isPinnedContextTraceCaller(caller: string | undefined): boolean {
|
||||
const id = (caller ?? "").trim();
|
||||
return id === "worker:design-flow" || id.endsWith(":design-flow");
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文;
|
||||
* 更早的消息删除 contextTrace 字段(消息本身保留)。
|
||||
* design-flow 痕迹钉住不删(keep=0 仍全部清除)。
|
||||
*/
|
||||
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
|
||||
messages: T[],
|
||||
@@ -88,12 +95,15 @@ export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>
|
||||
return rest as T;
|
||||
});
|
||||
}
|
||||
const withTraceIdx: number[] = [];
|
||||
const droppableIdx: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].contextTrace) withTraceIdx.push(i);
|
||||
const trace = messages[i].contextTrace;
|
||||
if (!trace) continue;
|
||||
if (isPinnedContextTraceCaller(trace.caller)) continue;
|
||||
droppableIdx.push(i);
|
||||
}
|
||||
if (withTraceIdx.length <= keep) return messages;
|
||||
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep));
|
||||
if (droppableIdx.length <= keep) return messages;
|
||||
const drop = new Set(droppableIdx.slice(0, droppableIdx.length - keep));
|
||||
return messages.map((m, i) => {
|
||||
if (!drop.has(i) || !m.contextTrace) return m;
|
||||
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
|
||||
|
||||
+30
-1
@@ -60,6 +60,13 @@ export type WaitingReason =
|
||||
kind: "next_intent";
|
||||
afterWorkerId?: string;
|
||||
afterUnitId?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* DAG 已在:按依赖层级点选节点。依赖未齐的不能点;
|
||||
* 可反复能力以 role=prototype 槽位呈现;点原型增殖 instance 再进去。
|
||||
*/
|
||||
kind: "pick_creation_step";
|
||||
};
|
||||
|
||||
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
||||
@@ -232,6 +239,26 @@ export type RuntimeEvent =
|
||||
type: "user_rejected_next_step";
|
||||
payload: { decisionId: string; reason?: string };
|
||||
}
|
||||
| {
|
||||
/** 回到流程编排:在已验收步骤上追加/改排节点(不整局重开) */
|
||||
type: "user_requested_flow_replan";
|
||||
payload: { reason?: string };
|
||||
}
|
||||
| {
|
||||
/** DAG 图上点选一条已就绪的步骤,进入 design-step */
|
||||
type: "user_picked_creation_step";
|
||||
payload: { stepId: string };
|
||||
}
|
||||
| {
|
||||
/** 验收后 / 总管误调 design-step:停在分层图上等用户点选 */
|
||||
type: "creation_step_pick_awaited";
|
||||
payload?: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
/** 离开当前技能步,回到分层图点选(误点增殖节点时用) */
|
||||
type: "user_left_creation_step";
|
||||
payload?: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
type: "worker_started";
|
||||
payload: {
|
||||
@@ -331,10 +358,12 @@ export type PhaseEffect =
|
||||
| { type: "resume_worker" }
|
||||
| { type: "run_programmatic_review"; artifactId: string }
|
||||
| { type: "emit_message"; message: string }
|
||||
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
|
||||
/** 根据流程与用户下一步意向,提案下一节点或停在分层图上等点选 */
|
||||
| { type: "propose_next_creation_step" }
|
||||
/** 选定开场白:落库、关 DAG、标记创作收口 */
|
||||
| { type: "seal_creation_opening" }
|
||||
/** 再编排:解开开场收口、把 DAG 改回 open */
|
||||
| { type: "unseal_creation_opening" }
|
||||
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
|
||||
| { type: "run_play_turn" }
|
||||
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
|
||||
|
||||
+126
-63
@@ -1,6 +1,7 @@
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js";
|
||||
import { supportsContentStream } from "../llm/stream-complete.js";
|
||||
import { completeStructured } from "../llm/structured-complete.js";
|
||||
import { resolveActiveProfile } from "../config/settings.js";
|
||||
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
||||
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
|
||||
import { collectUserInputTranscript } from "../intake/intake.js";
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
looksLikeProseNotSpec,
|
||||
parseWorkerSetYaml,
|
||||
} from "../skills/worker-set-parse.js";
|
||||
import {
|
||||
selectJsonPayload,
|
||||
tryParseJsonDoc,
|
||||
} from "../parse/json-doc.js";
|
||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||
import { isPlayLayerActive } from "../skills/play-turn.js";
|
||||
import { loadAppSettings } from "../config/settings.js";
|
||||
@@ -21,6 +26,10 @@ import { resolveActivePreset } from "../preset/store.js";
|
||||
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
|
||||
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
|
||||
import { PRESENT_TAG } from "../skills/present-packet.js";
|
||||
import {
|
||||
coerceToFlatArtifact,
|
||||
flatArtifactToFragmentDoc,
|
||||
} from "../skills/flat-artifact.js";
|
||||
import {
|
||||
gmChanceToolsEnabled,
|
||||
GM_CHANCE_HARNESS_INSTRUCTION,
|
||||
@@ -36,7 +45,10 @@ import {
|
||||
isUsableContextFragment,
|
||||
looksLikeFragmentDoc,
|
||||
} from "../skills/context-fragment.js";
|
||||
import { isProgressPointerTag } from "../skills/creation-flow.js";
|
||||
import {
|
||||
isProgressPointerTag,
|
||||
looksLikeCreationFlowDoc,
|
||||
} from "../skills/creation-flow.js";
|
||||
import {
|
||||
CONTEXT_ORDER_TAG,
|
||||
parseContextOrder,
|
||||
@@ -93,28 +105,19 @@ const WORKER_OUTPUT_INSTRUCTION = `
|
||||
|
||||
---
|
||||
|
||||
## 运行时输出协议(必须遵守)
|
||||
## 运行时输出协议
|
||||
|
||||
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
|
||||
{
|
||||
"outputs": { "<outputTag>": "<内容字符串>" },
|
||||
"summary": "50字以内产物摘要",
|
||||
"askUser": null 或 问题数组
|
||||
}
|
||||
输出 **一个 JSON 对象**(由程序按模型能力用 schema / tool / json_object 约束投递)。
|
||||
|
||||
askUser 每项可为:
|
||||
- 字符串:"需要用户补充的问题"
|
||||
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
|
||||
按本步 SKILL 规定的产物形状写。有正文的技能稿按该步 \`正文\` 对象写(柱下到条目/诊断三元组),不要把业务字段改成一段 \`sections[].text\`。
|
||||
「设计.创作流程」必须是带 \`steps\` 数组的 DAG,worker 集必须是带 \`workers\` 的规格。
|
||||
|
||||
- outputs 的 key 必须是要求的 outputTags
|
||||
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
|
||||
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
|
||||
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
|
||||
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
|
||||
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
|
||||
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
|
||||
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
||||
- summary 用于界面展示`;
|
||||
也兼容:
|
||||
1. 直接输出本步产物对象(创作流程 / context-fragment.v1 / worker 集等)
|
||||
2. \`{ "outputs": { "<本步 tag>": { … } }, "summary":"…", "askUser": null }\`(values 用对象)
|
||||
|
||||
不要写入进度指针。追问用 \`questions\`(或旧 \`追问.题目\`);\`questions: []\` 表示可验收。
|
||||
「开放问题」留给后续步。残稿也要交。summary 用于界面展示。`;
|
||||
|
||||
function slotValueForTag(
|
||||
tag: string,
|
||||
@@ -193,6 +196,13 @@ function assignOutput(
|
||||
}
|
||||
|
||||
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
|
||||
function existingOutputIsProduct(text: string | undefined): boolean {
|
||||
if (!text?.trim()) return false;
|
||||
if (isUsableContextFragment(text)) return true;
|
||||
const parsed = tryParseJsonDoc(text);
|
||||
return looksLikeCreationFlowDoc(parsed) || looksLikeFragmentDoc(parsed);
|
||||
}
|
||||
|
||||
function recoverFragmentOutputs(
|
||||
obj: Record<string, unknown>,
|
||||
outputs: Record<string, string>,
|
||||
@@ -200,7 +210,7 @@ function recoverFragmentOutputs(
|
||||
): void {
|
||||
const target = productOutputTags(outputTags)[0];
|
||||
if (!target) return;
|
||||
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
|
||||
if (existingOutputIsProduct(outputs[target])) return;
|
||||
|
||||
const outputsRaw =
|
||||
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
|
||||
@@ -212,7 +222,11 @@ function recoverFragmentOutputs(
|
||||
if (key === target) continue;
|
||||
const text = stringifyOutputValue(val);
|
||||
if (!text) continue;
|
||||
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
|
||||
if (
|
||||
isUsableContextFragment(text) ||
|
||||
looksLikeFragmentDoc(val) ||
|
||||
looksLikeCreationFlowDoc(val)
|
||||
) {
|
||||
outputs[target] = text;
|
||||
return;
|
||||
}
|
||||
@@ -227,6 +241,13 @@ function recoverFragmentOutputs(
|
||||
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
|
||||
const source = looksLikeFragmentDoc(obj) ? obj : rest;
|
||||
outputs[target] = JSON.stringify(source);
|
||||
return;
|
||||
}
|
||||
if (looksLikeCreationFlowDoc(obj) || looksLikeCreationFlowDoc(rest)) {
|
||||
const source = looksLikeCreationFlowDoc(obj) ? obj : rest;
|
||||
if (!outputs[target]?.trim()) {
|
||||
outputs[target] = JSON.stringify(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,14 +304,26 @@ const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
|
||||
},
|
||||
]);
|
||||
|
||||
function lookupOutputValue(
|
||||
raw: Record<string, unknown>,
|
||||
tag: string,
|
||||
): unknown {
|
||||
if (raw[tag] != null) return raw[tag];
|
||||
const leaf = tag.includes(".") ? tag.slice(tag.lastIndexOf(".") + 1) : tag;
|
||||
if (!leaf) return undefined;
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (val == null) continue;
|
||||
if (key === leaf || key.endsWith(`.${leaf}`)) return val;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseWorkerResponse(
|
||||
raw: string,
|
||||
outputTags: string[],
|
||||
): WorkerRunResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target && looksLikeStructuredDraft(raw)) {
|
||||
return finalizeParsedOutputs(
|
||||
@@ -322,17 +355,43 @@ function parseWorkerResponse(
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Worker 返回了无效 JSON");
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉)
|
||||
if (looksLikeCreationFlowDoc(obj)) {
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target) {
|
||||
return finalizeParsedOutputs(
|
||||
{ [target]: JSON.stringify(obj) },
|
||||
obj,
|
||||
outputTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。
|
||||
if (!looksLikeFragmentDoc(obj)) {
|
||||
const asFlat = coerceToFlatArtifact(obj);
|
||||
if (asFlat) {
|
||||
const frag = flatArtifactToFragmentDoc(asFlat);
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target) {
|
||||
return finalizeParsedOutputs(
|
||||
{ [target]: JSON.stringify(frag) },
|
||||
{ ...frag, summary: asFlat.summary || obj.summary },
|
||||
outputTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const outputsRaw = obj.outputs;
|
||||
const outputs: Record<string, string> = {};
|
||||
|
||||
if (outputsRaw && typeof outputsRaw === "object") {
|
||||
if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) {
|
||||
const bag = outputsRaw as Record<string, unknown>;
|
||||
for (const tag of productOutputTags(outputTags)) {
|
||||
assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
|
||||
assignOutput(outputs, tag, lookupOutputValue(bag, tag));
|
||||
}
|
||||
}
|
||||
recoverFragmentOutputs(obj, outputs, outputTags);
|
||||
@@ -484,25 +543,6 @@ function extractQuestionsFromText(text: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function completeWorkerPreferStream(
|
||||
llm: LlmProvider,
|
||||
messages: Parameters<LlmProvider["complete"]>[0],
|
||||
options: Parameters<LlmProvider["complete"]>[1],
|
||||
stream?: WorkerStreamCallbacks,
|
||||
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
|
||||
const callbacks: StreamCallbacks = {
|
||||
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
|
||||
};
|
||||
if (supportsContentStream(llm)) {
|
||||
return llm.completeStream!(messages, options, callbacks);
|
||||
}
|
||||
const result = await llm.complete(messages, options);
|
||||
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
|
||||
const workerId = resolveWorkerId(params.workerId);
|
||||
let worker: ParsedWorkerSkill;
|
||||
@@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
params.slots.creationAcceptedUnits ??
|
||||
params.blackboard.getContentByTag("创作.已验收单位");
|
||||
let acceptedStepNames: string[] = [];
|
||||
let inheritExisting = false;
|
||||
try {
|
||||
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
|
||||
const { parseAcceptedSteps, isInheritExistingFlag } = await import(
|
||||
"../skills/creation-flow.js"
|
||||
);
|
||||
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
|
||||
inheritExisting = isInheritExistingFlag(
|
||||
params.blackboard.getContentByTag("创作.继承修改"),
|
||||
);
|
||||
} catch {
|
||||
acceptedStepNames = [];
|
||||
}
|
||||
@@ -538,6 +584,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
acceptedStepNames,
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags,
|
||||
inheritExisting,
|
||||
},
|
||||
);
|
||||
worker = loaded.worker;
|
||||
@@ -616,20 +663,36 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
stream: params.stream,
|
||||
caller: `worker:${worker.id}`,
|
||||
});
|
||||
return parseWorkerResponse(harness.content, worker.outputTags);
|
||||
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
|
||||
}
|
||||
|
||||
const result = await completeWorkerPreferStream(
|
||||
params.llm,
|
||||
messages,
|
||||
{
|
||||
responseFormat: "json_object",
|
||||
caller: `worker:${worker.id}`,
|
||||
},
|
||||
params.stream,
|
||||
);
|
||||
const streamCallbacks: StreamCallbacks | undefined = params.stream
|
||||
? {
|
||||
onReasoningDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => params.stream?.onOutputDelta?.(delta),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return parseWorkerResponse(result.content, worker.outputTags);
|
||||
const result = await completeStructured(params.llm, messages, {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
name: "submit_worker_result",
|
||||
schemaIsLoose: true,
|
||||
capabilities: resolveActiveProfile()?.capabilities,
|
||||
caller: `worker:${worker.id}`,
|
||||
stream: streamCallbacks,
|
||||
});
|
||||
|
||||
const raw =
|
||||
typeof result.parsed === "string"
|
||||
? result.parsed
|
||||
: JSON.stringify(result.parsed);
|
||||
return parseWorkerResponse(
|
||||
selectJsonPayload(raw, result.reasoning) || raw,
|
||||
worker.outputTags,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal 供单测 */
|
||||
|
||||
@@ -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("报复爽");
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,29 @@ describe("pruneContextTraces", () => {
|
||||
expect(pruned.map((m) => m.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("pins design-flow traces while pruning others", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "flow",
|
||||
contextTrace: buildContextTrace({
|
||||
caller: "worker:design-flow",
|
||||
messages: [{ role: "user", content: "plan" }],
|
||||
}),
|
||||
},
|
||||
msg("1", true),
|
||||
msg("2", true),
|
||||
msg("3", true),
|
||||
];
|
||||
const pruned = pruneContextTraces(messages, 1);
|
||||
expect(pruned[0]?.contextTrace?.caller).toBe("worker:design-flow");
|
||||
expect(pruned.map((m) => Boolean(m.contextTrace))).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it("is a no-op when under the limit", () => {
|
||||
const messages = [msg("1", true), msg("2", true)];
|
||||
const pruned = pruneContextTraces(messages, 5);
|
||||
|
||||
+393
-34
@@ -6,6 +6,8 @@ import {
|
||||
artifactTagForStep,
|
||||
extractModuleOpening,
|
||||
formatCreationFlowForUser,
|
||||
deriveFlowStepTitle,
|
||||
titleFromSuggestion,
|
||||
formatFlowProgressForAgent,
|
||||
formatModuleCatalogForAgent,
|
||||
formatRecipeCatalogForAgent,
|
||||
@@ -18,6 +20,13 @@ import {
|
||||
mergeCreationFlowPreservingAccepted,
|
||||
needsFlowExpansion,
|
||||
nextPendingStep,
|
||||
listReadySteps,
|
||||
computeStepLayers,
|
||||
spawnRepeatableCreationStep,
|
||||
spawnInstanceFromPrototype,
|
||||
hasSelectableCreationWork,
|
||||
removeUnstartedInstance,
|
||||
looksLikeCreationFlowDoc,
|
||||
parseCreationFlow,
|
||||
pickRecordedStepId,
|
||||
parseModuleCatalog,
|
||||
@@ -29,6 +38,7 @@ import {
|
||||
shouldSkipModuleOpening,
|
||||
isProgressPointerTag,
|
||||
stringifyCreationFlow,
|
||||
stripReviseSteps,
|
||||
creationFlowFromRecipeSeed,
|
||||
validateCreationFlow,
|
||||
validateStepReadyToRun,
|
||||
@@ -51,6 +61,10 @@ modules:
|
||||
declaration: 锚定实例
|
||||
artifact: 设计.具体实例
|
||||
repeatable: true
|
||||
- name: 开场白与开场变量
|
||||
declaration: 收口
|
||||
artifact: 设计.开场白与开场变量
|
||||
closer: true
|
||||
`)!;
|
||||
|
||||
describe("creation-flow", () => {
|
||||
@@ -145,7 +159,44 @@ describe("creation-flow", () => {
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
expect(merged.restored).toEqual([]);
|
||||
expect(merged.raw).toBe(rewritten);
|
||||
expect(parseCreationFlow(merged.raw)!.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"实现机制",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops planner revise steps when merging flow", () => {
|
||||
const prev = `{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] }
|
||||
]
|
||||
}`;
|
||||
const rewritten = `{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{
|
||||
"id": "美学纲领与交互范式·改",
|
||||
"name": "美学纲领与交互范式",
|
||||
"mode": "revise",
|
||||
"revises": "美学纲领与交互范式",
|
||||
"depends_on": ["美学纲领与交互范式"]
|
||||
},
|
||||
{ "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式·改"] }
|
||||
]
|
||||
}`;
|
||||
const merged = mergeCreationFlowPreservingAccepted({
|
||||
prevRaw: prev,
|
||||
nextRaw: rewritten,
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
const flow = parseCreationFlow(merged.raw)!;
|
||||
expect(flow.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"实现机制",
|
||||
]);
|
||||
expect(flow.steps[1]?.depends_on).toEqual(["美学纲领与交互范式"]);
|
||||
});
|
||||
|
||||
it("allows duplicate capability names with distinct ids", () => {
|
||||
@@ -185,6 +236,24 @@ describe("creation-flow", () => {
|
||||
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式");
|
||||
});
|
||||
|
||||
it("extracts JSON from a code fence", () => {
|
||||
const flow = parseCreationFlow(
|
||||
"```json\n{\"brief\":\"单角代入\",\"steps\":[{\"name\":\"美学纲领与交互范式\",\"depends_on\":[]}]}\n```",
|
||||
);
|
||||
expect(flow?.brief).toBe("单角代入");
|
||||
expect(flow?.steps[0]?.name).toBe("美学纲领与交互范式");
|
||||
});
|
||||
|
||||
it("recognizes a DAG object and ignores brief-only objects", () => {
|
||||
expect(
|
||||
looksLikeCreationFlowDoc({
|
||||
brief: "单角代入",
|
||||
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(looksLikeCreationFlowDoc({ brief: "单角代入" })).toBe(false);
|
||||
});
|
||||
|
||||
it("validates deps must appear earlier", () => {
|
||||
const bad = parseCreationFlow(`{
|
||||
"steps": [
|
||||
@@ -250,13 +319,93 @@ describe("creation-flow", () => {
|
||||
depends_on: [],
|
||||
declaration: "站位与体验契约",
|
||||
});
|
||||
expect(view.steps[2]?.occurrence).toBe(2);
|
||||
expect(view.steps[2]?.role).toBe("instance");
|
||||
expect(view.steps[2]?.occurrence).toBe(1);
|
||||
expect(view.steps[1]?.role).toBe("prototype");
|
||||
expect(view.steps[2]?.repeatable).toBe(true);
|
||||
expect(view.steps[0]?.runState).toBe("current");
|
||||
expect(view.steps[0]?.runState).toBe("pending");
|
||||
expect(view.steps[0]?.layer).toBe(0);
|
||||
expect(view.steps[0]?.ready).toBe(true);
|
||||
expect(view.steps[0]?.selectable).toBe(true);
|
||||
expect(view.steps[1]?.runState).toBe("pending");
|
||||
expect(view.steps[1]?.layer).toBe(1);
|
||||
expect(view.steps[1]?.ready).toBe(false);
|
||||
expect(view.steps[2]?.runState).toBe("pending");
|
||||
});
|
||||
|
||||
it("shows a short title for 生成规则 / 具体实例 prototypes and instances", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{
|
||||
"id": "生成规则",
|
||||
"name": "生成规则",
|
||||
"role": "prototype",
|
||||
"suggestion": "幸存者生成规则:锚定出租屋内的女性租客",
|
||||
"depends_on": []
|
||||
},
|
||||
{
|
||||
"id": "生成规则#1",
|
||||
"name": "生成规则",
|
||||
"role": "instance",
|
||||
"from": "生成规则",
|
||||
"depends_on": []
|
||||
},
|
||||
{
|
||||
"id": "具体实例",
|
||||
"name": "具体实例",
|
||||
"role": "prototype",
|
||||
"suggestion": "按幸存者规则生成开局女租客",
|
||||
"depends_on": ["生成规则"]
|
||||
},
|
||||
{
|
||||
"id": "具体实例#1",
|
||||
"name": "具体实例",
|
||||
"role": "instance",
|
||||
"from": "具体实例",
|
||||
"params": { "batch_goal": "开局女租客", "rule_id": "survivors" },
|
||||
"depends_on": ["生成规则#1"]
|
||||
}
|
||||
]
|
||||
}`)!;
|
||||
const untitled = formatCreationFlowForUser(flow, sampleCatalog);
|
||||
expect(untitled.steps.find((s) => s.id === "生成规则")?.title).toBe(
|
||||
"幸存者生成规则",
|
||||
);
|
||||
expect(untitled.steps.find((s) => s.id === "生成规则#1")?.title).toBe(
|
||||
"幸存者生成规则",
|
||||
);
|
||||
expect(untitled.steps.find((s) => s.id === "具体实例")?.title).toBe(
|
||||
"按幸存者规则生成开局女租客",
|
||||
);
|
||||
expect(untitled.steps.find((s) => s.id === "具体实例#1")?.title).toBe(
|
||||
"开局女租客",
|
||||
);
|
||||
|
||||
const accepted = formatCreationFlowForUser(flow, sampleCatalog, [], [], {
|
||||
"生成规则#1": { summary: "生成规则 · 幸存者 · 预生成并动态" },
|
||||
"具体实例#1": { summary: "具体实例 · 女租客 · 2条" },
|
||||
});
|
||||
expect(accepted.steps.find((s) => s.id === "生成规则#1")?.title).toBe(
|
||||
"幸存者",
|
||||
);
|
||||
expect(accepted.steps.find((s) => s.id === "具体实例#1")?.title).toBe(
|
||||
"女租客",
|
||||
);
|
||||
expect(
|
||||
deriveFlowStepTitle({
|
||||
name: "生成规则",
|
||||
hint: {
|
||||
content: {
|
||||
正文: { 本步参数: { target: "女租客" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("女租客");
|
||||
expect(titleFromSuggestion("怪物生成规则:按族群抽样")).toBe(
|
||||
"怪物生成规则",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks accepted steps done and the next pending current", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"brief": "网恋回合",
|
||||
@@ -272,9 +421,10 @@ describe("creation-flow", () => {
|
||||
]);
|
||||
expect(view.steps.map((s) => s.runState)).toEqual([
|
||||
"done",
|
||||
"current",
|
||||
"pending",
|
||||
"pending",
|
||||
]);
|
||||
expect(view.steps[1]?.selectable).toBe(true);
|
||||
});
|
||||
|
||||
it("loads world-simulator catalog and formats for agent", async () => {
|
||||
@@ -317,7 +467,7 @@ describe("creation-flow", () => {
|
||||
expect(block).toContain("上下文投影排序〔程序步〕");
|
||||
expect(block).toContain("何时用");
|
||||
expect(block).toContain("何时不用");
|
||||
expect(block).toContain("规划产物字段");
|
||||
expect(block).toContain("role=prototype");
|
||||
expect(block).toContain("编排参数");
|
||||
expect(block).not.toContain("设计.美学纲领与交互范式");
|
||||
});
|
||||
@@ -403,6 +553,22 @@ steps:
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("creationFlowFromRecipeSeed follows the recipe steps, not a fixed first node", () => {
|
||||
const detail = parseRecipeYaml(
|
||||
`
|
||||
when: 其它剧本
|
||||
steps:
|
||||
- name: 舞台骨架
|
||||
depends_on: []
|
||||
- name: 实现机制
|
||||
depends_on: ["舞台骨架"]
|
||||
`,
|
||||
{ id: "other", name: "其它", declaration: "测" },
|
||||
);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
expect(seeded?.steps.map((s) => s.name)).toEqual(["舞台骨架", "实现机制"]);
|
||||
});
|
||||
|
||||
it("falls back to legacy hint when methodology absent", () => {
|
||||
const detail = parseRecipeYaml(
|
||||
`
|
||||
@@ -478,7 +644,7 @@ recipes:
|
||||
expect(loaded.promptBody).toContain("美学纲领与交互范式");
|
||||
expect(loaded.promptBody).toContain("已验收");
|
||||
expect(loaded.promptBody).toContain("禁止再排新建");
|
||||
expect(loaded.promptBody).toContain("mode=revise");
|
||||
expect(loaded.promptBody).toContain("禁止排 mode=revise");
|
||||
expect(loaded.promptBody).toContain("生成规则");
|
||||
});
|
||||
|
||||
@@ -496,7 +662,36 @@ recipes:
|
||||
expect(text).toContain("草案已有、尚未验收");
|
||||
expect(text).toContain("美学纲领与交互范式");
|
||||
expect(text).toContain("不要当作新规划再写一遍");
|
||||
expect(text).toContain("可反复追加:生成规则、具体实例");
|
||||
expect(text).toContain("目录〔可反复〕:生成规则、具体实例");
|
||||
});
|
||||
|
||||
it("tells design-flow to insert nodes before an unaccepted closer", () => {
|
||||
const catalog = parseModuleCatalog(`
|
||||
modules:
|
||||
- name: 美学纲领与交互范式
|
||||
declaration: 站位
|
||||
artifact: 设计.美学纲领与交互范式
|
||||
- name: 开场白与开场变量
|
||||
declaration: 收口
|
||||
artifact: 设计.开场白与开场变量
|
||||
closer: true
|
||||
`)!;
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "closed",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式"] }
|
||||
]
|
||||
}`)!;
|
||||
const text = formatFlowProgressForAgent({
|
||||
flow,
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
catalog,
|
||||
});
|
||||
expect(text).toContain("〔收口〕");
|
||||
expect(text).toContain("开场白与开场变量");
|
||||
expect(text).toContain("插在收口之前");
|
||||
expect(text).toContain("status 改回 open");
|
||||
});
|
||||
|
||||
it("injects aesthetics-interaction module prompt into design-step", async () => {
|
||||
@@ -637,7 +832,7 @@ recipes:
|
||||
expect(missingView.steps[0]?.paramsMissing).toBeUndefined();
|
||||
|
||||
const block = formatModuleCatalogForAgent(catalog!);
|
||||
expect(block).toContain("规划产物字段");
|
||||
expect(block).toContain("role=prototype");
|
||||
expect(block).toContain("target");
|
||||
});
|
||||
|
||||
@@ -662,8 +857,7 @@ recipes:
|
||||
acceptedStepNames: [],
|
||||
},
|
||||
);
|
||||
expect(loaded.promptBody).toContain("【规划产物】");
|
||||
expect(loaded.promptBody).not.toContain("勿再问「生成什么");
|
||||
expect(loaded.promptBody).toContain("【本步对象】");
|
||||
expect(loaded.promptBody).toContain("target: 怪物");
|
||||
expect(loaded.promptBody).toContain("rule_id: monsters");
|
||||
expect(loaded.worker.outputTags).toContain("设计.生成规则");
|
||||
@@ -693,14 +887,22 @@ modules:
|
||||
undefined,
|
||||
{
|
||||
flowRaw: JSON.stringify({
|
||||
steps: [{ id: "生成规则", name: "生成规则", depends_on: [] }],
|
||||
steps: [
|
||||
{
|
||||
id: "生成规则#1",
|
||||
name: "生成规则",
|
||||
role: "instance",
|
||||
from: "生成规则",
|
||||
depends_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
currentStepName: "生成规则",
|
||||
currentStepName: "生成规则#1",
|
||||
acceptedStepNames: [],
|
||||
},
|
||||
);
|
||||
expect(loaded.promptBody).toContain("【规划产物】");
|
||||
expect(loaded.promptBody).toContain("尚未规划本步具体写什么");
|
||||
expect(loaded.promptBody).toContain("【本步对象】");
|
||||
expect(loaded.promptBody).toContain("尚未钉本步具体写什么");
|
||||
});
|
||||
|
||||
it("nextPendingStep respects deps and accepted by id", () => {
|
||||
@@ -780,7 +982,7 @@ modules:
|
||||
expect(isCreationFlowComplete(legacy, ["美学纲领与交互范式"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allows revising a non-repeatable skill and infers revises + id", () => {
|
||||
it("parses legacy revise steps then strips them from the user graph", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
@@ -795,10 +997,35 @@ modules:
|
||||
revises: "实现机制",
|
||||
});
|
||||
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
|
||||
const stripped = stripReviseSteps(flow);
|
||||
expect(stripped.steps.map((s) => s.id)).toEqual(["实现机制"]);
|
||||
const view = formatCreationFlowForUser(flow, sampleCatalog, ["实现机制"]);
|
||||
expect(view.steps[1]?.mode).toBe("revise");
|
||||
expect(view.steps[1]?.occurrence).toBeUndefined();
|
||||
expect(view.steps[1]?.runState).toBe("current");
|
||||
expect(view.steps).toHaveLength(1);
|
||||
expect(view.steps[0]?.id).toBe("实现机制");
|
||||
expect(view.steps[0]?.runState).toBe("done");
|
||||
expect(view.steps[0]?.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stripReviseSteps remaps dependents back to the origin step", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "id": "美学纲领与交互范式", "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{
|
||||
"id": "美学纲领与交互范式·改",
|
||||
"name": "美学纲领与交互范式",
|
||||
"mode": "revise",
|
||||
"revises": "美学纲领与交互范式",
|
||||
"depends_on": ["美学纲领与交互范式"]
|
||||
},
|
||||
{ "id": "正文组成", "name": "实现机制", "depends_on": ["美学纲领与交互范式·改"] }
|
||||
]
|
||||
}`)!;
|
||||
const stripped = stripReviseSteps(flow);
|
||||
expect(stripped.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"正文组成",
|
||||
]);
|
||||
expect(stripped.steps[1]?.depends_on).toEqual(["美学纲领与交互范式"]);
|
||||
});
|
||||
|
||||
it("still rejects two fresh copies of a non-repeatable skill", () => {
|
||||
@@ -849,7 +1076,7 @@ modules:
|
||||
expect(validateCreationFlow(flow, sampleCatalog).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("nextPendingStep prefers a ready revise over a later fresh step", () => {
|
||||
it("nextPendingStep ignores orchestrated revise steps", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
@@ -863,31 +1090,26 @@ modules:
|
||||
}
|
||||
]
|
||||
}`)!;
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBe("revise");
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe(
|
||||
"美学纲领与交互范式·改",
|
||||
);
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.mode).toBeUndefined();
|
||||
expect(nextPendingStep(flow, ["美学纲领与交互范式"])?.id).toBe("生成规则");
|
||||
expect(
|
||||
listReadySteps(flow, ["美学纲领与交互范式"]).map((s) => s.id),
|
||||
).toEqual(["生成规则"]);
|
||||
});
|
||||
|
||||
it("injects inherit draft and skips opening on a revise design-step", async () => {
|
||||
it("inherits existing artifact when reentering an accepted step", async () => {
|
||||
const flowRaw = JSON.stringify({
|
||||
status: "open",
|
||||
steps: [
|
||||
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
|
||||
{
|
||||
id: "美学纲领与交互范式·改",
|
||||
name: "美学纲领与交互范式",
|
||||
mode: "revise",
|
||||
revises: "美学纲领与交互范式",
|
||||
depends_on: ["美学纲领与交互范式"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const binding = await resolveDesignStepBinding({
|
||||
skillPackRoot: "dialogue/world-simulator",
|
||||
flowRaw,
|
||||
currentStepName: "美学纲领与交互范式·改",
|
||||
currentStepName: "美学纲领与交互范式",
|
||||
acceptedStepNames: ["美学纲领与交互范式"],
|
||||
inheritExisting: true,
|
||||
});
|
||||
expect(binding?.opening).toBeNull();
|
||||
expect(binding?.inheritTag).toBe("设计.美学纲领与交互范式");
|
||||
@@ -898,16 +1120,153 @@ modules:
|
||||
undefined,
|
||||
{
|
||||
flowRaw,
|
||||
currentStepName: "美学纲领与交互范式·改",
|
||||
currentStepName: "美学纲领与交互范式",
|
||||
acceptedStepNames: ["美学纲领与交互范式"],
|
||||
inheritExisting: true,
|
||||
},
|
||||
);
|
||||
expect(loaded.promptBody).toContain("【回头修改】");
|
||||
expect(loaded.promptBody).not.toContain("程序开场");
|
||||
expect(loaded.worker.name).toContain("回头修改");
|
||||
expect(loaded.worker.inputTags).toContain("设计.美学纲领与交互范式");
|
||||
});
|
||||
|
||||
it("layers follow longest dependency path", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "name": "具体实例", "depends_on": ["生成规则"] },
|
||||
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式", "具体实例"] }
|
||||
]
|
||||
}`)!;
|
||||
const layers = computeStepLayers(flow);
|
||||
expect(layers.get("美学纲领与交互范式")).toBe(0);
|
||||
expect(layers.get("生成规则")).toBe(1);
|
||||
expect(layers.get("具体实例")).toBe(2);
|
||||
expect(layers.get("开场白与开场变量")).toBe(3);
|
||||
});
|
||||
|
||||
it("listReadySteps returns every unblocked pending step", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "id": "实现机制", "name": "实现机制", "depends_on": ["美学纲领与交互范式"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(listReadySteps(flow, []).map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
]);
|
||||
expect(
|
||||
loaded.worker.contextSegments?.some((s) => s.id === "inherit-existing"),
|
||||
listReadySteps(flow, ["美学纲领与交互范式"]).map((s) => s.id),
|
||||
).toEqual(["生成规则", "实现机制"]);
|
||||
});
|
||||
|
||||
it("spawns instance from prototype before closer and hangs instance on closer deps", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式"] }
|
||||
]
|
||||
}`)!;
|
||||
expect(
|
||||
hasSelectableCreationWork(flow, sampleCatalog, ["美学纲领与交互范式"]),
|
||||
).toBe(true);
|
||||
const spawned = spawnRepeatableCreationStep({
|
||||
flow,
|
||||
catalog: sampleCatalog,
|
||||
moduleName: "生成规则",
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
expect("error" in spawned).toBe(false);
|
||||
if ("error" in spawned) return;
|
||||
expect(spawned.step.id).toBe("生成规则#1");
|
||||
expect(spawned.step.role).toBe("instance");
|
||||
expect(spawned.step.depends_on).toEqual(["美学纲领与交互范式"]);
|
||||
expect(spawned.flow.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"生成规则",
|
||||
"生成规则#1",
|
||||
"开场白与开场变量",
|
||||
]);
|
||||
expect(
|
||||
spawned.flow.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
|
||||
).toEqual(["美学纲领与交互范式", "生成规则#1"]);
|
||||
|
||||
const second = spawnRepeatableCreationStep({
|
||||
flow: spawned.flow,
|
||||
catalog: sampleCatalog,
|
||||
moduleName: "生成规则",
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
expect("error" in second).toBe(false);
|
||||
if ("error" in second) return;
|
||||
expect(second.step.id).toBe("生成规则#2");
|
||||
});
|
||||
|
||||
it("removeUnstartedInstance drops empty spawn and closer dep", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"status": "open",
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{ "id": "生成规则", "name": "生成规则", "role": "prototype", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "id": "生成规则#1", "name": "生成规则", "role": "instance", "from": "生成规则", "depends_on": ["美学纲领与交互范式"] },
|
||||
{ "name": "开场白与开场变量", "depends_on": ["美学纲领与交互范式", "生成规则#1"] }
|
||||
]
|
||||
}`)!;
|
||||
const next = removeUnstartedInstance(flow, "生成规则#1", []);
|
||||
expect(next.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"生成规则",
|
||||
"开场白与开场变量",
|
||||
]);
|
||||
expect(
|
||||
next.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
|
||||
).toEqual(["美学纲领与交互范式"]);
|
||||
const kept = removeUnstartedInstance(next, "美学纲领与交互范式", []);
|
||||
expect(kept.steps.map((s) => s.id)).toEqual(next.steps.map((s) => s.id));
|
||||
const accepted = removeUnstartedInstance(flow, "生成规则#1", ["生成规则#1"]);
|
||||
expect(accepted.steps.some((s) => s.id === "生成规则#1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks 具体实例 spawn until a 生成规则 instance is accepted", () => {
|
||||
const flow = parseCreationFlow(`{
|
||||
"steps": [
|
||||
{ "name": "美学纲领与交互范式", "depends_on": [] },
|
||||
{
|
||||
"id": "生成规则",
|
||||
"name": "生成规则",
|
||||
"role": "prototype",
|
||||
"depends_on": ["美学纲领与交互范式"]
|
||||
}
|
||||
]
|
||||
}`)!;
|
||||
const blocked = spawnRepeatableCreationStep({
|
||||
flow,
|
||||
catalog: sampleCatalog,
|
||||
moduleName: "具体实例",
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
expect(blocked).toMatchObject({ error: "先完成一条生成规则" });
|
||||
const ruleSpawn = spawnInstanceFromPrototype({
|
||||
flow,
|
||||
catalog: sampleCatalog,
|
||||
prototypeId: "生成规则",
|
||||
acceptedStepIds: ["美学纲领与交互范式"],
|
||||
});
|
||||
expect("error" in ruleSpawn).toBe(false);
|
||||
if ("error" in ruleSpawn) return;
|
||||
const withRule = ruleSpawn.flow;
|
||||
const ok = spawnRepeatableCreationStep({
|
||||
flow: withRule,
|
||||
catalog: sampleCatalog,
|
||||
moduleName: "具体实例",
|
||||
acceptedStepIds: ["美学纲领与交互范式", "生成规则#1"],
|
||||
});
|
||||
expect("error" in ok).toBe(false);
|
||||
if ("error" in ok) return;
|
||||
expect(ok.step.depends_on).toEqual(["生成规则#1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,21 @@ import {
|
||||
import { parseWorkerSetYaml } from "../src/skills/worker-set-parse.js";
|
||||
|
||||
describe("resolveAcceptanceModeForWorker", () => {
|
||||
it("always confirms design-flow", () => {
|
||||
it("auto-continues design-flow without a confirm step", () => {
|
||||
const mode = resolveAcceptanceModeForWorker({
|
||||
session: createSession(),
|
||||
blackboard: new Blackboard(),
|
||||
workerId: "design-flow",
|
||||
});
|
||||
expect(mode).toBe("no_confirmation");
|
||||
});
|
||||
|
||||
it("still confirms design-step", () => {
|
||||
const mode = resolveAcceptanceModeForWorker({
|
||||
session: createSession(),
|
||||
blackboard: new Blackboard(),
|
||||
workerId: "design-step",
|
||||
});
|
||||
expect(mode).toBe("user_confirmed");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
displayStageLabel,
|
||||
displayWorkerLabel,
|
||||
formatWorkerDisplayTitle,
|
||||
isFlowPlanReview,
|
||||
reviewComposerCopy,
|
||||
} from "../src/server/display-labels.js";
|
||||
|
||||
@@ -52,4 +53,25 @@ describe("display-labels", () => {
|
||||
expect(setup.acceptLabel).toBe("选定此开场");
|
||||
expect(setup.kind).toBe("opening");
|
||||
});
|
||||
|
||||
it("treats design-flow review as the pick-node surface", () => {
|
||||
expect(
|
||||
isFlowPlanReview({
|
||||
waitingReason: { kind: "review_artifact" },
|
||||
reviewArtifact: { workerId: "design-flow" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFlowPlanReview({
|
||||
waitingReason: { kind: "review_artifact" },
|
||||
reviewArtifact: { workerId: "design-step" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFlowPlanReview({
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
reviewArtifact: { workerId: "design-flow" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: "点题",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isOpeningSealArtifact,
|
||||
parseOpeningSealPayload,
|
||||
openingVariablesToTableDoc,
|
||||
reopenCreationFlowRaw,
|
||||
} from "../src/skills/opening-seal.js";
|
||||
|
||||
const fragment = JSON.stringify({
|
||||
@@ -71,4 +72,14 @@ describe("opening-seal", () => {
|
||||
const closed = JSON.parse(closeCreationFlowRaw(raw)!);
|
||||
expect(closed.status).toBe("closed");
|
||||
});
|
||||
|
||||
it("reopens a closed creation flow", () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
status: "closed",
|
||||
steps: [{ id: "开场白与开场变量", name: "开场白与开场变量", depends_on: [] }],
|
||||
});
|
||||
const opened = JSON.parse(reopenCreationFlowRaw(raw)!);
|
||||
expect(opened.status).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "点题" });
|
||||
});
|
||||
});
|
||||
@@ -541,4 +541,160 @@ describe("phase machine", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejecting the next step goes back to design-flow instead of the main agent", () => {
|
||||
let session = createSession("default");
|
||||
const decision = {
|
||||
id: "d1",
|
||||
action: "run_worker" as const,
|
||||
reason: "下一步开场白",
|
||||
workerId: "design-step",
|
||||
requiresApproval: true,
|
||||
statePatchAllowed: false as const,
|
||||
};
|
||||
session = {
|
||||
...session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "approve_step", decisionId: decision.id },
|
||||
pendingDecision: decision,
|
||||
};
|
||||
|
||||
const rejected = applyEvent(session, {
|
||||
type: "user_rejected_next_step",
|
||||
payload: { decisionId: decision.id, reason: "还没做 NPC" },
|
||||
});
|
||||
expect(rejected.session.phase).toBe("running");
|
||||
expect(rejected.session.slots["用户.下一步意向"]).toBe("还没做 NPC");
|
||||
expect(rejected.session.pendingDecision).toBeUndefined();
|
||||
expect(rejected.effects).toEqual([
|
||||
{ type: "unseal_creation_opening" },
|
||||
{ type: "run_worker", workerId: "design-flow" },
|
||||
]);
|
||||
expect(rejected.effects.some((e) => e.type === "invoke_main_agent")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("replan from opening review runs design-flow without revising the opening", () => {
|
||||
let session = createSession("default");
|
||||
const artifact = createArtifact({
|
||||
workerId: "design-step",
|
||||
stepId: "开场白与开场变量",
|
||||
outputTags: ["设计.开场白与开场变量"],
|
||||
});
|
||||
session = {
|
||||
...session,
|
||||
artifacts: [artifact],
|
||||
pendingArtifactId: artifact.id,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "review_artifact", artifactId: artifact.id },
|
||||
slots: { creationSealedByOpening: true },
|
||||
};
|
||||
|
||||
const replanned = applyEvent(session, {
|
||||
type: "user_requested_flow_replan",
|
||||
payload: { reason: "漏了开局 NPC" },
|
||||
});
|
||||
expect(replanned.session.phase).toBe("running");
|
||||
expect(replanned.session.pendingArtifactId).toBeUndefined();
|
||||
expect(replanned.session.slots.creationSealedByOpening).toBeUndefined();
|
||||
expect(replanned.session.artifacts[0]?.status).toBe("rejected");
|
||||
expect(replanned.session.slots["用户.修订说明"]).toBe("漏了开局 NPC");
|
||||
expect(replanned.effects).toEqual([
|
||||
{ type: "unseal_creation_opening" },
|
||||
{ type: "run_worker", workerId: "design-flow" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("awaits pick on the layered graph and starts the clicked step", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "running",
|
||||
};
|
||||
const awaited = applyEvent(session, {
|
||||
type: "creation_step_pick_awaited",
|
||||
payload: {},
|
||||
});
|
||||
expect(awaited.session.phase).toBe("waiting_user");
|
||||
expect(awaited.session.waitingReason?.kind).toBe("pick_creation_step");
|
||||
expect(awaited.session.currentWorkerId).toBeUndefined();
|
||||
|
||||
const picked = applyEvent(awaited.session, {
|
||||
type: "user_picked_creation_step",
|
||||
payload: { stepId: "生成规则" },
|
||||
});
|
||||
expect(picked.session.phase).toBe("running");
|
||||
expect(picked.session.currentWorkerId).toBe("design-step");
|
||||
expect(picked.effects).toEqual([
|
||||
{ type: "run_worker", workerId: "design-step" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaving a creation step returns to the pick graph", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: {
|
||||
kind: "worker_questions",
|
||||
workerId: "design-step",
|
||||
questions: [{ id: "module-opening", prompt: "引导" }],
|
||||
},
|
||||
currentWorkerId: "design-step",
|
||||
currentStepId: "生成规则#1",
|
||||
};
|
||||
const left = applyEvent(session, {
|
||||
type: "user_left_creation_step",
|
||||
payload: {},
|
||||
});
|
||||
expect(left.session.phase).toBe("waiting_user");
|
||||
expect(left.session.waitingReason?.kind).toBe("pick_creation_step");
|
||||
expect(left.session.currentWorkerId).toBeUndefined();
|
||||
expect(left.session.currentStepId).toBeUndefined();
|
||||
expect(left.session.resumeContext).toBeUndefined();
|
||||
expect(left.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it("replan from the pick graph runs design-flow", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
};
|
||||
const replanned = applyEvent(session, {
|
||||
type: "user_requested_flow_replan",
|
||||
payload: { reason: "补舞台骨架" },
|
||||
});
|
||||
expect(replanned.session.phase).toBe("running");
|
||||
expect(replanned.session.slots["用户.下一步意向"]).toBe("补舞台骨架");
|
||||
expect(replanned.effects).toEqual([
|
||||
{ type: "unseal_creation_opening" },
|
||||
{ type: "run_worker", workerId: "design-flow" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("design-flow without confirmation proposes the pick graph", () => {
|
||||
let session = createSession("default");
|
||||
session = {
|
||||
...session,
|
||||
phase: "running",
|
||||
acceptanceMode: "no_confirmation",
|
||||
};
|
||||
const artifact = createArtifact({
|
||||
workerId: "design-flow",
|
||||
outputTags: ["设计.创作流程"],
|
||||
});
|
||||
session = { ...session, artifacts: [artifact] };
|
||||
const completed = applyEvent(session, {
|
||||
type: "worker_completed",
|
||||
payload: { artifactId: artifact.id },
|
||||
});
|
||||
expect(completed.session.artifacts[0]?.status).toBe("accepted");
|
||||
expect(completed.session.pendingArtifactId).toBeUndefined();
|
||||
expect(completed.effects).toEqual([
|
||||
{ type: "propose_next_creation_step" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { PhaseRuntime, createDecision } from "../src/runtime/phase-runtime.js";
|
||||
import { createSession } from "../src/runtime/phase-machine.js";
|
||||
import { Blackboard } from "../src/blackboard/blackboard.js";
|
||||
import { parseCreationFlow } from "../src/skills/creation-flow.js";
|
||||
|
||||
describe("phase runtime", () => {
|
||||
it("auto-loads default orchestrator and awaits first user input", async () => {
|
||||
@@ -129,4 +130,167 @@ describe("phase runtime", () => {
|
||||
await runtime.start();
|
||||
await expect(runtime.retryStuckRun()).rejects.toThrow("当前没有可重试的执行");
|
||||
});
|
||||
|
||||
it("first input with seeded DAG waits for node pick instead of calling main agent", async () => {
|
||||
const llm = new MockLlmProvider(["Let me analyze the DAG in English prose"]);
|
||||
const runtime = new PhaseRuntime({ llm });
|
||||
await runtime.start();
|
||||
runtime.getBlackboard().write({
|
||||
tag: "设计.创作流程",
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
status: "open",
|
||||
steps: [
|
||||
{
|
||||
id: "美学纲领与交互范式",
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
source: "runtime",
|
||||
});
|
||||
await runtime.submitInput("丧尸世界,但只有我不会被感染");
|
||||
expect(runtime.getSession().phase).toBe("waiting_user");
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
|
||||
});
|
||||
|
||||
it("enterSeededCreationPick skips first demand and waits on the seeded graph", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
runtime.getBlackboard().write({
|
||||
tag: "设计.创作流程",
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
status: "open",
|
||||
steps: [
|
||||
{
|
||||
id: "舞台骨架",
|
||||
name: "舞台骨架",
|
||||
depends_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
source: "runtime",
|
||||
});
|
||||
await runtime.enterSeededCreationPick();
|
||||
expect(runtime.getSession().slots.startupCompleted).toBe(true);
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
|
||||
});
|
||||
|
||||
it("unusable main agent output without DAG starts design-flow", async () => {
|
||||
const llm = new MockLlmProvider(["Let me think about routing"]);
|
||||
const runtime = new PhaseRuntime({ llm, autoStubWorker: true });
|
||||
await runtime.start();
|
||||
await runtime.submitInput("网恋对象对话");
|
||||
expect(runtime.getSession().phase).not.toBe("running");
|
||||
expect(
|
||||
runtime.getSession().artifacts.some((a) => a.workerId === "design-flow"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("failRun leaves error phase instead of running", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
await runtime.submitInput("随便写点");
|
||||
expect(runtime.getSession().phase).toBe("running");
|
||||
await runtime.failRun("fetch failed");
|
||||
expect(runtime.getSession().phase).toBe("error");
|
||||
expect(runtime.getSession().waitingReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recoverOrphanedRun turns stuck running into node pick when DAG exists", async () => {
|
||||
const runtime = new PhaseRuntime();
|
||||
await runtime.start();
|
||||
runtime.getBlackboard().write({
|
||||
tag: "设计.创作流程",
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
status: "open",
|
||||
steps: [
|
||||
{
|
||||
id: "美学纲领与交互范式",
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
source: "runtime",
|
||||
});
|
||||
const session = runtime.getSession();
|
||||
session.phase = "running";
|
||||
session.waitingReason = undefined;
|
||||
expect(runtime.recoverOrphanedRun()).toBe(true);
|
||||
expect(runtime.getSession().phase).toBe("waiting_user");
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
|
||||
});
|
||||
|
||||
it("leaveCreationStep returns to pick and unsawns an empty instance", async () => {
|
||||
const session = createSession("default");
|
||||
session.phase = "waiting_user";
|
||||
session.waitingReason = {
|
||||
kind: "worker_questions",
|
||||
workerId: "design-step",
|
||||
questions: [{ id: "module-opening", prompt: "生成规则引导" }],
|
||||
};
|
||||
session.currentWorkerId = "design-step";
|
||||
session.slots = { startupCompleted: true };
|
||||
const blackboard = new Blackboard();
|
||||
blackboard.write({
|
||||
tag: "设计.创作流程",
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
status: "open",
|
||||
steps: [
|
||||
{
|
||||
id: "美学纲领与交互范式",
|
||||
name: "美学纲领与交互范式",
|
||||
depends_on: [],
|
||||
},
|
||||
{
|
||||
id: "生成规则",
|
||||
name: "生成规则",
|
||||
role: "prototype",
|
||||
depends_on: ["美学纲领与交互范式"],
|
||||
},
|
||||
{
|
||||
id: "生成规则#1",
|
||||
name: "生成规则",
|
||||
role: "instance",
|
||||
from: "生成规则",
|
||||
depends_on: ["美学纲领与交互范式"],
|
||||
},
|
||||
{
|
||||
id: "开场白与开场变量",
|
||||
name: "开场白与开场变量",
|
||||
depends_on: ["美学纲领与交互范式", "生成规则#1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
source: "runtime",
|
||||
});
|
||||
blackboard.write({
|
||||
tag: "创作.当前步骤",
|
||||
content: "生成规则#1",
|
||||
source: "runtime",
|
||||
});
|
||||
const runtime = new PhaseRuntime({
|
||||
initialSession: session,
|
||||
initialBlackboardItems: blackboard.exportItems(),
|
||||
});
|
||||
await runtime.leaveCreationStep();
|
||||
expect(runtime.getSession().waitingReason?.kind).toBe("pick_creation_step");
|
||||
expect(runtime.getSession().currentWorkerId).toBeUndefined();
|
||||
const flow = parseCreationFlow(
|
||||
runtime.getBlackboard().getContentByTag("设计.创作流程"),
|
||||
);
|
||||
expect(flow?.steps.map((s) => s.id)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"生成规则",
|
||||
"开场白与开场变量",
|
||||
]);
|
||||
expect(
|
||||
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
|
||||
).toEqual(["美学纲领与交互范式"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,4 +127,32 @@ describe("session persistence", () => {
|
||||
|
||||
deleteBook(book.id);
|
||||
});
|
||||
|
||||
it("createForBook with recipe lands on that recipe's seeded pick graph", async () => {
|
||||
const { createBook, deleteBook } = await import("../src/book/store.js");
|
||||
const { SessionManager } = await import("../src/server/session-manager.js");
|
||||
|
||||
const book = createBook({ title: "配方开局" });
|
||||
const mgr = new SessionManager();
|
||||
const view = await mgr.createForBook(
|
||||
book.id,
|
||||
"world-simulator",
|
||||
"world-simulator",
|
||||
);
|
||||
expect(view.waitingReason?.kind).toBe("pick_creation_step");
|
||||
expect(view.startupCompleted).toBe(true);
|
||||
expect(view.uiPrompt).toBeUndefined();
|
||||
expect(view.openingGuide).toBeNull();
|
||||
const { loadAllRecipeDetails } = await import(
|
||||
"../src/skills/creation-flow.js"
|
||||
);
|
||||
const details = await loadAllRecipeDetails("dialogue/world-simulator");
|
||||
const seedNames = details
|
||||
.find((d) => d.id === "world-simulator")
|
||||
?.seed?.steps.map((s) => s.name);
|
||||
expect(seedNames?.length).toBeGreaterThan(0);
|
||||
expect(view.creationFlowView?.steps.map((s) => s.name)).toEqual(seedNames);
|
||||
|
||||
deleteBook(book.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createMockToolCall,
|
||||
MockLlmProvider,
|
||||
} from "../src/llm/client.js";
|
||||
import { isUnusableMainAgentOutput } from "../src/main-agent/main-agent.js";
|
||||
import { runMainAgentToolLoop } from "../src/main-agent/tool-loop.js";
|
||||
import { MAIN_AGENT_TOOL_DEFINITIONS } from "../src/main-agent/tools.js";
|
||||
import { toolCallToDecision } from "../src/runtime/tool-registry.js";
|
||||
@@ -29,6 +30,15 @@ describe("main agent tool loop", () => {
|
||||
).toEqual(["read_blackboard", "run_worker"]);
|
||||
});
|
||||
|
||||
it("detects unusable main agent output", () => {
|
||||
expect(
|
||||
isUnusableMainAgentOutput(
|
||||
new Error("Main Agent returned invalid JSON: Let me analyze"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isUnusableMainAgentOutput(new Error("fetch failed"))).toBe(false);
|
||||
});
|
||||
|
||||
it("runs loop tools then returns terminal decision", async () => {
|
||||
const llm = new MockLlmProvider([
|
||||
createMockToolCall("read_blackboard", { tags: ["book.brief"] }),
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
parseWorkerResponseForTest,
|
||||
sanitizeWorkerSetOutputs,
|
||||
} from "../src/worker/executor.js";
|
||||
import { selectJsonPayload } from "../src/parse/json-doc.js";
|
||||
import { parseCreationFlow } from "../src/skills/creation-flow.js";
|
||||
|
||||
describe("worker-set prose rejection", () => {
|
||||
it("does not YAML-crash on Chinese prose; returns clear parseError", () => {
|
||||
@@ -155,6 +157,39 @@ describe("sanitizeWorkerSetOutputs", () => {
|
||||
expect(result.askUser?.[0]?.prompt).not.toContain("为贴近你要的质感");
|
||||
});
|
||||
|
||||
it("keeps nested aesthetics 正文 objects for specialty cards", () => {
|
||||
const frag = {
|
||||
schema: "context-fragment.v1",
|
||||
技能: "美学纲领与交互范式",
|
||||
brief: "孤立免疫",
|
||||
正文: {
|
||||
设定逻辑: {
|
||||
参与方式: {
|
||||
用户与user关系: {
|
||||
结论: "完全代入",
|
||||
完备度: "80%",
|
||||
依据: "用户明确只有我不会被感染",
|
||||
},
|
||||
},
|
||||
},
|
||||
交互范式: { 前置配置: { 人称: "你" } },
|
||||
美学纲领: { 体验内核: "特权与惊惶" },
|
||||
},
|
||||
自评: {
|
||||
维度: [{ 名: "美学纲领", 分数: 8, 说明: "点题" }],
|
||||
},
|
||||
};
|
||||
const result = parseWorkerResponseForTest(JSON.stringify(frag), [
|
||||
"设计.美学纲领与交互范式",
|
||||
]);
|
||||
const parsed = JSON.parse(result.outputs["设计.美学纲领与交互范式"]!);
|
||||
const body = parsed.正文;
|
||||
expect(typeof body.设定逻辑).toBe("object");
|
||||
expect(body.设定逻辑.参与方式.用户与user关系.结论).toBe("完全代入");
|
||||
expect(body.美学纲领.体验内核).toBe("特权与惊惶");
|
||||
expect(parsed.自评.维度[0].分数).toBe(8);
|
||||
});
|
||||
|
||||
it("recovers a root-level context-fragment into the artifact tag", () => {
|
||||
const frag = {
|
||||
schema: "context-fragment.v1",
|
||||
@@ -173,6 +208,46 @@ describe("sanitizeWorkerSetOutputs", () => {
|
||||
expect(result.askUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts unprefixed outputs keys and JSON sitting after thinking prose", () => {
|
||||
const frag = {
|
||||
schema: "context-fragment.v1",
|
||||
技能: "美学纲领与交互范式",
|
||||
brief: "孤立免疫",
|
||||
正文: { 美学纲领: { 体验内核: "人人录我" } },
|
||||
};
|
||||
const prefixed = parseWorkerResponseForTest(
|
||||
JSON.stringify({
|
||||
outputs: { "美学纲领与交互范式": frag },
|
||||
summary: "ok",
|
||||
}),
|
||||
["设计.美学纲领与交互范式"],
|
||||
);
|
||||
expect(prefixed.outputs["设计.美学纲领与交互范式"]).toContain("人人录我");
|
||||
|
||||
const mixed = parseWorkerResponseForTest(
|
||||
`We must output JSON. Example { "foo": 1 }.\n${JSON.stringify(frag)}`,
|
||||
["设计.美学纲领与交互范式"],
|
||||
);
|
||||
expect(mixed.outputs["设计.美学纲领与交互范式"]).toContain("人人录我");
|
||||
});
|
||||
|
||||
it("uses product JSON in reasoning when content is a closing sentence", () => {
|
||||
const frag = {
|
||||
schema: "context-fragment.v1",
|
||||
技能: "美学纲领与交互范式",
|
||||
brief: "点题",
|
||||
正文: { 美学纲领: { 体验内核: "内核" } },
|
||||
};
|
||||
const payload = selectJsonPayload(
|
||||
"The artifact is ready.",
|
||||
JSON.stringify(frag),
|
||||
);
|
||||
const result = parseWorkerResponseForTest(payload, [
|
||||
"设计.美学纲领与交互范式",
|
||||
]);
|
||||
expect(result.outputs["设计.美学纲领与交互范式"]).toContain("内核");
|
||||
});
|
||||
|
||||
it("moves a fragment written to 创作.当前步骤 onto the artifact tag", () => {
|
||||
const frag = {
|
||||
schema: "context-fragment.v1",
|
||||
@@ -257,6 +332,51 @@ describe("sanitizeWorkerSetOutputs", () => {
|
||||
expect(doc.play_slots.gm).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a root-level creation flow DAG on 设计.创作流程", () => {
|
||||
const dag = {
|
||||
brief: "单角代入",
|
||||
status: "open",
|
||||
steps: [
|
||||
{ id: "美学纲领与交互范式", name: "美学纲领与交互范式", depends_on: [] },
|
||||
{
|
||||
id: "生成规则",
|
||||
name: "生成规则",
|
||||
role: "prototype",
|
||||
depends_on: ["美学纲领与交互范式"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseWorkerResponseForTest(JSON.stringify(dag), [
|
||||
"设计.创作流程",
|
||||
]);
|
||||
const parsed = parseCreationFlow(result.outputs["设计.创作流程"]);
|
||||
expect(parsed?.brief).toBe("单角代入");
|
||||
expect(parsed?.steps.map((s) => s.name)).toEqual([
|
||||
"美学纲领与交互范式",
|
||||
"生成规则",
|
||||
]);
|
||||
expect(result.outputs["设计.创作流程"]).not.toContain("context-fragment");
|
||||
expect(result.askUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps creation flow written under outputs bag", () => {
|
||||
const result = parseWorkerResponseForTest(
|
||||
JSON.stringify({
|
||||
outputs: {
|
||||
"设计.创作流程": {
|
||||
status: "open",
|
||||
steps: [{ name: "美学纲领与交互范式", depends_on: [] }],
|
||||
},
|
||||
},
|
||||
summary: "流程 · 1 步 · open",
|
||||
}),
|
||||
["设计.创作流程"],
|
||||
);
|
||||
expect(parseCreationFlow(result.outputs["设计.创作流程"])?.steps[0]?.name).toBe(
|
||||
"美学纲领与交互范式",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a whole-response truncated JSON as the artifact draft", () => {
|
||||
const raw =
|
||||
'{"outputs":{"设计.上下文投影排序":{"schema":"context-order.v1","brief":"旁观+转述","agents":[';
|
||||
|
||||
+816
-192
File diff suppressed because it is too large
Load Diff
+259
-65
@@ -1,7 +1,7 @@
|
||||
import { renderWorkspace, updateLiveStreamPanel, resetRailChrome } from "./agent-ui.js";
|
||||
import { renderWorkspace, updateLiveStreamPanel, resetRailChrome, fillStepArtifactDialog } from "./agent-ui.js";
|
||||
import { downloadMarkdown, sessionToMarkdown } from "./export.js";
|
||||
import { renderIntakePanel } from "./intake-ui.js";
|
||||
import { displaySkillPackLabel, reviewComposerCopy } from "./display-labels.js";
|
||||
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
|
||||
import {
|
||||
clearQuestionCardState,
|
||||
collectQuestionAnswers,
|
||||
@@ -17,9 +17,11 @@ let books = [];
|
||||
let composerForceInput = false;
|
||||
let pendingComposerDraft = "";
|
||||
let pendingComposerDraftSeq = 0;
|
||||
/** 正在提交底栏:重绘时不要把已捕获的正文再塞进 pending */
|
||||
let skipComposerStash = false;
|
||||
let sidebarNav = { level: "root" };
|
||||
let bookSelectMode = false;
|
||||
let selectedBookIds = new Set();
|
||||
const expandedBookIds = new Set();
|
||||
let activePlaySaveId = null;
|
||||
const playSavesByBook = new Map();
|
||||
const playSavesLoading = new Set();
|
||||
@@ -97,6 +99,15 @@ function resolveUserTask(view, loading) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") {
|
||||
return {
|
||||
id: "speak",
|
||||
label: "选节点",
|
||||
title: "选要做的节点",
|
||||
hint: "点可进入的节点即确认并开始;要改排在底栏写意见再发。",
|
||||
};
|
||||
}
|
||||
|
||||
if (wr?.kind === "review_artifact") {
|
||||
const copy = reviewCopyFromView(view);
|
||||
return {
|
||||
@@ -388,6 +399,22 @@ function resolveComposer(view, loading) {
|
||||
const send = view.actions?.find((a) => a.type === "send_message");
|
||||
const hasQuestionsCard = Boolean(getActiveQuestions(view)?.questions?.length);
|
||||
|
||||
if (
|
||||
(view.waitingReason?.kind === "pick_creation_step" || isFlowPlanReview(view)) &&
|
||||
!composerForceInput
|
||||
) {
|
||||
return {
|
||||
mode: "pick_step",
|
||||
task,
|
||||
taskTitle: "选要做的节点",
|
||||
taskHint: "点可进入的节点即确认并开始;虚线原型点一下增殖。要改排在底栏写意见再发。",
|
||||
placeholder: send?.placeholder ?? "例如:补舞台骨架、把某步改到开场白之前…",
|
||||
hint: null,
|
||||
submitLabel: "发送",
|
||||
requiresText: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (view.waitingReason?.kind === "review_artifact") {
|
||||
const parseBroken = Boolean(view.reviewArtifact?.workerSetView?.parseError);
|
||||
const copy = reviewCopyFromView(view);
|
||||
@@ -432,6 +459,7 @@ function resolveComposer(view, loading) {
|
||||
view.waitingReason?.kind === "worker_questions" ||
|
||||
view.waitingReason?.kind === "revision" ||
|
||||
view.waitingReason?.kind === "next_intent" ||
|
||||
view.waitingReason?.kind === "pick_creation_step" ||
|
||||
composerForceInput
|
||||
) {
|
||||
const wr = view.waitingReason;
|
||||
@@ -442,7 +470,7 @@ function resolveComposer(view, loading) {
|
||||
submitLabel = "继续";
|
||||
} else if (wr?.kind === "approve_step" && composerForceInput) {
|
||||
placeholder =
|
||||
"例如:改成丧尸怪物规则(后台 id 也换成 zombies)…";
|
||||
"例如:补开局 NPC、改成丧尸怪物规则(后台 id 也换成 zombies)…";
|
||||
submitLabel = "发送意见";
|
||||
} else if (wr?.kind === "revision") {
|
||||
placeholder = "说明要改哪里…";
|
||||
@@ -507,7 +535,38 @@ function composerInputShell(spec, { textareaHtml, trailing = "" } = {}) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function leaveStepButtonHtml(spec) {
|
||||
if (!spec.leaveStep) return "";
|
||||
return `<button type="button" class="btn" data-act="leave_step" title="${esc(
|
||||
spec.leaveStep.title || "先不写这一步,回到节点选择",
|
||||
)}">${esc(spec.leaveStep.label)}</button>`;
|
||||
}
|
||||
|
||||
function stashComposerDraftFromDom() {
|
||||
if (skipComposerStash) return;
|
||||
const input = $("composer-input");
|
||||
if (!input) return;
|
||||
const v = String(input.value ?? "");
|
||||
if (v) {
|
||||
pendingComposerDraft = v;
|
||||
pendingComposerDraftSeq = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function keepComposerDraft(text) {
|
||||
const v = String(text ?? "");
|
||||
if (!v) return;
|
||||
pendingComposerDraft = v;
|
||||
pendingComposerDraftSeq = 0;
|
||||
}
|
||||
|
||||
function dropComposerDraft() {
|
||||
pendingComposerDraft = "";
|
||||
pendingComposerDraftSeq = 0;
|
||||
}
|
||||
|
||||
function renderComposer(view, loading) {
|
||||
stashComposerDraftFromDom();
|
||||
if (pendingComposerDraft) composerForceInput = true;
|
||||
const host = $("composer");
|
||||
const root = $("composer-main") || host;
|
||||
@@ -517,6 +576,13 @@ function renderComposer(view, loading) {
|
||||
host._enterHandler = null;
|
||||
}
|
||||
const spec = resolveComposer(view, loading);
|
||||
const leaveAct = view.actions?.find((a) => a.type === "leave_step");
|
||||
if (leaveAct && spec.mode !== "waiting" && spec.mode !== "idle") {
|
||||
spec.leaveStep = {
|
||||
label: leaveAct.label || "返回节点",
|
||||
title: "先不写这一步,回到节点选择",
|
||||
};
|
||||
}
|
||||
|
||||
if (spec.mode === "waiting") {
|
||||
root.innerHTML = `<div class="composer-waiting">
|
||||
@@ -556,7 +622,7 @@ function renderComposer(view, loading) {
|
||||
|
||||
if (spec.mode === "action") {
|
||||
const reject = spec.showReject
|
||||
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "暂不" : "不接受"}</button>
|
||||
? `<button type="button" class="btn" data-act="reject">${spec.primaryType === "approve" ? "返回节点" : "不接受"}</button>
|
||||
<button type="button" class="btn" data-act="force-input">说明意见</button>`
|
||||
: "";
|
||||
const chip = composerModeChip(spec);
|
||||
@@ -611,6 +677,9 @@ function renderComposer(view, loading) {
|
||||
: "具体写什么在步骤内确定。"
|
||||
}</p>`
|
||||
: "";
|
||||
const closerHint = p.closer
|
||||
? `<p class="propose-plan-hint">这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。</p>`
|
||||
: "";
|
||||
root.innerHTML = `
|
||||
<div class="propose-step" id="propose-step" data-step-id="${esc(p.stepId)}">
|
||||
<div class="propose-step-head">
|
||||
@@ -619,11 +688,12 @@ function renderComposer(view, loading) {
|
||||
</div>
|
||||
<p class="propose-output" data-propose-output>${esc(proposedOutputCopy(p))}</p>
|
||||
${planHint}
|
||||
${closerHint}
|
||||
${fields ? `<div class="propose-fields">${fields}</div>` : ""}
|
||||
${syncHint}
|
||||
<div class="composer-actions">
|
||||
<button type="button" class="btn btn-primary" data-act="approve">${esc(spec.primary.label)}</button>
|
||||
<button type="button" class="btn" data-act="reject">暂不</button>
|
||||
<button type="button" class="btn" data-act="reject" title="先不写这一步,回到节点选择">返回节点</button>
|
||||
<button type="button" class="btn" data-act="force-input">改意见</button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -687,6 +757,17 @@ function renderComposer(view, loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spec.mode === "pick_step") {
|
||||
const shell = composerInputShell(spec, {
|
||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
||||
trailing: `<button type="submit" class="btn btn-primary composer-btn" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
||||
});
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
wireComposerForm({ requiresText: true });
|
||||
applyPendingComposerDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptOnEmpty = Boolean(spec.acceptAction);
|
||||
const acceptTone = spec.acceptAction?.tone || "accept";
|
||||
const acceptBtn = spec.acceptAction
|
||||
@@ -705,7 +786,7 @@ function renderComposer(view, loading) {
|
||||
: spec.placeholder;
|
||||
const shell = composerInputShell(spec, {
|
||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(placeholder)}"></textarea>`,
|
||||
trailing: `<button type="button" class="btn composer-btn${
|
||||
trailing: `${leaveStepButtonHtml(spec)}<button type="button" class="btn composer-btn${
|
||||
sendIsPrimary ? " btn-primary" : ""
|
||||
}" data-act="revise" title="${esc(
|
||||
acceptOnEmpty
|
||||
@@ -716,6 +797,7 @@ function renderComposer(view, loading) {
|
||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||
wireComposerForm({ acceptOnEmpty });
|
||||
root.querySelector("[data-act=accept]")?.addEventListener("click", () => runAction("accept"));
|
||||
root.querySelector("[data-act=leave_step]")?.addEventListener("click", () => runAction("leave_step"));
|
||||
applyPendingComposerDraft();
|
||||
}
|
||||
|
||||
@@ -752,6 +834,7 @@ function applyPendingComposerDraft() {
|
||||
pendingComposerDraft = "";
|
||||
pendingComposerDraftSeq = 0;
|
||||
autosizeComposerInput(input);
|
||||
syncPickReplanButton(input);
|
||||
input.focus();
|
||||
refreshReviewComposerChrome();
|
||||
}
|
||||
@@ -804,10 +887,21 @@ function syncDualComposerPrimary(input, acceptBtn, sendBtn) {
|
||||
sendBtn.classList.toggle("btn-primary", revisePrimary);
|
||||
}
|
||||
|
||||
function syncPickReplanButton(input) {
|
||||
const btn = $("composer-form")?.querySelector("[data-act=pick-replan]");
|
||||
if (!btn) return;
|
||||
const hasText = Boolean(String(input?.value ?? "").trim());
|
||||
btn.disabled = !hasText;
|
||||
btn.title = hasText
|
||||
? "发送改编排意见"
|
||||
: "写下意见后再发;点图上节点即确认并进入";
|
||||
}
|
||||
|
||||
function wireComposerForm(opts = {}) {
|
||||
const form = $("composer-form");
|
||||
const input = $("composer-input");
|
||||
const acceptOnEmpty = opts.acceptOnEmpty === true;
|
||||
const requiresText = opts.requiresText === true;
|
||||
const sendBtn = form?.querySelector("[data-act=revise]");
|
||||
const root = $("composer");
|
||||
if (root?._enterHandler) {
|
||||
@@ -816,12 +910,12 @@ function wireComposerForm(opts = {}) {
|
||||
}
|
||||
form?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await submitComposer(input?.value ?? "", { acceptOnEmpty });
|
||||
await submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
|
||||
});
|
||||
input?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
void submitComposer(input?.value ?? "", { acceptOnEmpty });
|
||||
void submitComposer(input?.value ?? "", { acceptOnEmpty, requiresText });
|
||||
}
|
||||
});
|
||||
sendBtn?.addEventListener("click", () => {
|
||||
@@ -846,6 +940,7 @@ function wireComposerForm(opts = {}) {
|
||||
const grow = () => {
|
||||
autosizeComposerInput(input);
|
||||
if (acceptOnEmpty) refreshReviewComposerChrome();
|
||||
if (requiresText) syncPickReplanButton(input);
|
||||
};
|
||||
input?.addEventListener("input", grow);
|
||||
input?.addEventListener("change", grow);
|
||||
@@ -868,12 +963,6 @@ function bookPlayReady(bookId) {
|
||||
return saves.some((s) => s.kind === "instance" || s.kind === "run");
|
||||
}
|
||||
|
||||
function bookHasPlayBranch(bookId) {
|
||||
if (bookPlayReady(bookId)) return true;
|
||||
if (playSavesLoading.has(bookId) && !playSavesByBook.has(bookId)) return true;
|
||||
return playRunsForBook(bookId).length > 0;
|
||||
}
|
||||
|
||||
function formatSaveWhen(createdAt) {
|
||||
const when = new Date(createdAt);
|
||||
if (Number.isNaN(when.getTime())) return "";
|
||||
@@ -900,6 +989,7 @@ async function fetchPlaySavesForBook(bookId, force = false) {
|
||||
}
|
||||
|
||||
function setBookSelectMode(on, { keepSelection = false } = {}) {
|
||||
if (on && sidebarNav.level === "book") return;
|
||||
bookSelectMode = on;
|
||||
if (!on) selectedBookIds = new Set();
|
||||
else if (!keepSelection) selectedBookIds = new Set();
|
||||
@@ -962,8 +1052,50 @@ function showBookMenu(bookId, x, y) {
|
||||
}
|
||||
}
|
||||
|
||||
function expandBook(bookId) {
|
||||
if (bookId) expandedBookIds.add(bookId);
|
||||
function isCatalogNav() {
|
||||
return sidebarNav.level !== "book";
|
||||
}
|
||||
|
||||
function getNavBook() {
|
||||
if (sidebarNav.level !== "book") return null;
|
||||
return books.find((b) => b.id === sidebarNav.bookId) ?? null;
|
||||
}
|
||||
|
||||
function syncRailContext() {
|
||||
const el = $("rail-context");
|
||||
if (!el) return;
|
||||
const book = getNavBook();
|
||||
if (sidebarNav.level === "book" && book) {
|
||||
el.textContent = book.title;
|
||||
el.title = book.title;
|
||||
} else {
|
||||
el.textContent = "作品";
|
||||
el.removeAttribute("title");
|
||||
}
|
||||
$("rail-tabs")?.toggleAttribute("hidden", sidebarNav.level !== "book");
|
||||
}
|
||||
|
||||
function navigateToBook(bookId) {
|
||||
if (!bookId) return;
|
||||
sidebarNav = { level: "book", bookId };
|
||||
document.body.dataset.navLevel = "book";
|
||||
bookSelectMode = false;
|
||||
selectedBookIds = new Set();
|
||||
syncRailContext();
|
||||
renderBookList();
|
||||
void fetchPlaySavesForBook(bookId);
|
||||
}
|
||||
|
||||
function showCatalog() {
|
||||
beginUiRequest();
|
||||
stopLivePoll();
|
||||
sidebarNav = { level: "root" };
|
||||
document.body.dataset.navLevel = "root";
|
||||
bookSelectMode = false;
|
||||
selectedBookIds = new Set();
|
||||
closeWorkspace();
|
||||
syncRailContext();
|
||||
renderBookList();
|
||||
}
|
||||
|
||||
function renderExplorerRow({
|
||||
@@ -1025,7 +1157,7 @@ function renderExplorerRow({
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderBookBranch(book, list) {
|
||||
function renderBookBranch(book, list, { nested = false } = {}) {
|
||||
const loading = playSavesLoading.has(book.id);
|
||||
const saves = playSavesByBook.get(book.id);
|
||||
const ready = bookPlayReady(book.id);
|
||||
@@ -1045,7 +1177,7 @@ function renderBookBranch(book, list) {
|
||||
name: "当前",
|
||||
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
|
||||
active: playHere && !activePlaySaveId,
|
||||
child: true,
|
||||
child: nested,
|
||||
onClick: () => void openCurrentPlay(book.id),
|
||||
}),
|
||||
);
|
||||
@@ -1058,7 +1190,7 @@ function renderBookBranch(book, list) {
|
||||
name: s.label,
|
||||
meta: when ? `游玩 · ${when}` : "游玩进度",
|
||||
active: playHere && activePlaySaveId === s.id,
|
||||
child: true,
|
||||
child: nested,
|
||||
actionClass: "explorer-file",
|
||||
onClick: () => void loadPlaySave(book.id, s.id),
|
||||
onDelete: () => void deletePlaySave(book.id, s.id, s.label),
|
||||
@@ -1071,16 +1203,30 @@ function renderBookBranch(book, list) {
|
||||
renderExplorerRow({
|
||||
name: "+ 新开一局",
|
||||
actionClass: "explorer-action",
|
||||
child: true,
|
||||
child: nested,
|
||||
onClick: () => void startNewPlayForBook(book.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookContents(book, list) {
|
||||
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: "创作",
|
||||
meta: skill,
|
||||
active: book.id === activeBookId && !isPlayNow(),
|
||||
onClick: () => void openBookDesign(book.id),
|
||||
}),
|
||||
);
|
||||
renderBookBranch(book, list, { nested: false });
|
||||
}
|
||||
|
||||
function renderBookList() {
|
||||
const list = $("book-list");
|
||||
if (!list) return;
|
||||
syncRailContext();
|
||||
if (!books.length) {
|
||||
list.innerHTML = `<p class="sidebar-empty">暂无作品<br><button type="button" class="btn-sm" id="btn-new-inline">+ 新建</button></p>`;
|
||||
$("btn-new-inline")?.addEventListener("click", openNewBookDialog);
|
||||
@@ -1088,39 +1234,36 @@ function renderBookList() {
|
||||
}
|
||||
list.innerHTML = "";
|
||||
|
||||
if (sidebarNav.level === "book") {
|
||||
const book = getNavBook();
|
||||
if (book) {
|
||||
renderBookContents(book, list);
|
||||
return;
|
||||
}
|
||||
sidebarNav = { level: "root" };
|
||||
document.body.dataset.navLevel = "root";
|
||||
syncRailContext();
|
||||
}
|
||||
|
||||
for (const book of books) {
|
||||
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
|
||||
const selected = selectedBookIds.has(book.id);
|
||||
const expanded = expandedBookIds.has(book.id);
|
||||
const hasBranch = bookHasPlayBranch(book.id);
|
||||
const bookActive = book.id === activeBookId && !isPlayNow() && !selected;
|
||||
list.appendChild(
|
||||
renderExplorerRow({
|
||||
name: book.title,
|
||||
meta: skill,
|
||||
active: bookActive,
|
||||
selected,
|
||||
bookId: book.id,
|
||||
actionClass: "explorer-folder",
|
||||
twistie: hasBranch,
|
||||
expanded,
|
||||
onTwistie: () => {
|
||||
if (expandedBookIds.has(book.id)) expandedBookIds.delete(book.id);
|
||||
else expandedBookIds.add(book.id);
|
||||
renderBookList();
|
||||
if (!expandedBookIds.has(book.id)) return;
|
||||
void fetchPlaySavesForBook(book.id);
|
||||
},
|
||||
onClick: () => {
|
||||
if (bookSelectMode) {
|
||||
toggleBookSelection(book.id);
|
||||
return;
|
||||
}
|
||||
void openBookDesign(book.id);
|
||||
void openBook(book.id);
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (expanded && hasBranch) renderBookBranch(book, list);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1200,7 +1343,7 @@ async function deleteSelectedBooks() {
|
||||
books = books.filter((b) => !deleted.has(b.id));
|
||||
setBookSelectMode(false);
|
||||
if (activeBookId && deleted.has(activeBookId)) {
|
||||
if (books.length) await openBook(books[0].id);
|
||||
if (books.length) showCatalog();
|
||||
else renderEmpty();
|
||||
} else {
|
||||
renderBookList();
|
||||
@@ -1229,9 +1372,9 @@ function setupBookBoxSelect() {
|
||||
};
|
||||
|
||||
list.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (sidebarNav.level !== "root" || e.button !== 0) return;
|
||||
if (e.target.closest("#book-action-menu")) return;
|
||||
if (e.target.closest(".explorer-row.child")) return;
|
||||
if (e.target.closest(".explorer-row")) return;
|
||||
dragging = true;
|
||||
start = { x: e.clientX, y: e.clientY };
|
||||
overlay.hidden = true;
|
||||
@@ -1282,8 +1425,8 @@ function setupBookBoxSelect() {
|
||||
|
||||
async function loadPlaySave(bookId, saveId) {
|
||||
try {
|
||||
expandBook(bookId);
|
||||
if (lastView) renderSession(lastView, true);
|
||||
navigateToBook(bookId);
|
||||
if (lastView?.bookId === bookId) renderSession(lastView, true);
|
||||
const data = await api(
|
||||
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}/load`,
|
||||
{ method: "POST" },
|
||||
@@ -1316,8 +1459,8 @@ async function startNewPlayForBook(bookId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
expandBook(bookId);
|
||||
if (lastView) renderSession(lastView, true);
|
||||
navigateToBook(bookId);
|
||||
if (lastView?.bookId === bookId) renderSession(lastView, true);
|
||||
const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" });
|
||||
activePlaySaveId = null;
|
||||
renderSession(data.session, false);
|
||||
@@ -1328,7 +1471,7 @@ async function startNewPlayForBook(bookId) {
|
||||
}
|
||||
|
||||
async function openBookDesign(bookId) {
|
||||
expandBook(bookId);
|
||||
navigateToBook(bookId);
|
||||
activePlaySaveId = null;
|
||||
if (activeBookId !== bookId || !sessionId) {
|
||||
await openBook(bookId);
|
||||
@@ -1341,7 +1484,7 @@ async function openBookDesign(bookId) {
|
||||
}
|
||||
|
||||
async function openCurrentPlay(bookId) {
|
||||
expandBook(bookId);
|
||||
navigateToBook(bookId);
|
||||
const alreadyHere =
|
||||
activeBookId === bookId &&
|
||||
isPlayNow() &&
|
||||
@@ -1390,12 +1533,12 @@ function renderHeader(view, loading) {
|
||||
if (btnExport) btnExport.disabled = !(view.messages?.length);
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
function closeWorkspace({ emptyCopy = "" } = {}) {
|
||||
stopLivePoll();
|
||||
sessionId = null;
|
||||
lastView = null;
|
||||
activeBookId = null;
|
||||
activePlaySaveId = null;
|
||||
setBookSelectMode(false);
|
||||
composerForceInput = false;
|
||||
document.title = "Writing Agent";
|
||||
$("status-dot").className = "status-dot";
|
||||
@@ -1406,7 +1549,7 @@ function renderEmpty() {
|
||||
if (btnInst) btnInst.hidden = true;
|
||||
const btnExport = $("btn-export");
|
||||
if (btnExport) btnExport.disabled = true;
|
||||
$("message-feed").innerHTML = `<p class="empty">点击左侧 + 新建作品</p>`;
|
||||
$("message-feed").innerHTML = emptyCopy ? `<p class="empty">${emptyCopy}</p>` : "";
|
||||
const stage = $("workspace-stage");
|
||||
if (stage) {
|
||||
stage.hidden = true;
|
||||
@@ -1439,6 +1582,14 @@ function renderEmpty() {
|
||||
document.body.dataset.lifecycle = "design";
|
||||
const composerMain = $("composer-main");
|
||||
if (composerMain) composerMain.innerHTML = `<div class="composer-idle">暂无打开的作品</div>`;
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
sidebarNav = { level: "root" };
|
||||
document.body.dataset.navLevel = "root";
|
||||
bookSelectMode = false;
|
||||
selectedBookIds = new Set();
|
||||
closeWorkspace({ emptyCopy: "点击左侧 + 新建作品" });
|
||||
renderBookList();
|
||||
}
|
||||
|
||||
@@ -1490,12 +1641,14 @@ function startLivePoll() {
|
||||
}
|
||||
|
||||
function renderSession(view, loading = false) {
|
||||
if (isCatalogNav()) return;
|
||||
lastView = view;
|
||||
sessionId = view.id;
|
||||
activeBookId = view.bookId ?? activeBookId;
|
||||
if (
|
||||
!loading &&
|
||||
view.waitingReason?.kind !== "approve_step" &&
|
||||
view.waitingReason?.kind !== "pick_creation_step" &&
|
||||
view.waitingReason?.kind !== "review_artifact" &&
|
||||
view.waitingReason?.kind !== "worker_questions" &&
|
||||
!(view.waitingReason?.kind === "input" && view.waitingReason?.questions?.length)
|
||||
@@ -1524,6 +1677,12 @@ function renderSession(view, loading = false) {
|
||||
onSwitchVariant: (messageId, direction) =>
|
||||
messageAction("variant", messageId, { direction }),
|
||||
onRetryRun: () => retryRun(),
|
||||
onPickCreationStep: (stepId) => runAction("pick_step", { stepId }),
|
||||
onReenterCreationStep: (stepId) =>
|
||||
runAction("pick_step", { stepId, reenter: true }),
|
||||
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
|
||||
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
||||
onLeaveCreationStep: () => runAction("leave_step"),
|
||||
});
|
||||
renderComposer(view, loading);
|
||||
if (loading) startLivePoll();
|
||||
@@ -1573,8 +1732,12 @@ function selectedQuestionAnswers(view) {
|
||||
}
|
||||
|
||||
/** 空 Enter=接受;有字或已选追问则按意见改。点「按意见修改」不走这条。 */
|
||||
async function submitComposer(text, { acceptOnEmpty } = {}) {
|
||||
async function submitComposer(text, { acceptOnEmpty, requiresText } = {}) {
|
||||
const trimmed = String(text ?? "").trim();
|
||||
if (requiresText && !trimmed) {
|
||||
alert("点图上节点即确认并进入;要改编排请先写下意见。");
|
||||
return;
|
||||
}
|
||||
if (acceptOnEmpty && !trimmed) {
|
||||
const { answered } = selectedQuestionAnswers(lastView);
|
||||
if (answered.length === 0) {
|
||||
@@ -1609,7 +1772,11 @@ async function sendText(text) {
|
||||
if (reviewing) {
|
||||
const { collected, answered } = selectedQuestionAnswers(lastView);
|
||||
if (!trimmed && answered.length === 0) {
|
||||
alert("请选择追问选项或填写修改意见;满意请点「接受」收下产物。");
|
||||
alert(
|
||||
isFlowPlanReview(lastView)
|
||||
? "点图上节点即确认并进入;要改编排请先写下意见。"
|
||||
: "请选择追问选项或填写修改意见;满意请点「接受」收下产物。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1681,6 +1848,13 @@ async function sendText(text) {
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
if (
|
||||
lastView?.waitingReason?.kind === "pick_creation_step" ||
|
||||
isFlowPlanReview(lastView)
|
||||
) {
|
||||
alert("点图上节点即确认并进入;要改编排请先写下意见。");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lastView?.waitingReason?.kind === "next_intent" ||
|
||||
resolveComposer(lastView, false)?.allowEmpty
|
||||
@@ -1789,7 +1963,6 @@ async function loadBooks() {
|
||||
const data = await api("/api/books");
|
||||
books = data.books ?? [];
|
||||
renderBookList();
|
||||
for (const book of books) void fetchPlaySavesForBook(book.id);
|
||||
}
|
||||
|
||||
function openNewBookDialog() {
|
||||
@@ -1865,7 +2038,7 @@ async function createBook() {
|
||||
$("dialog-new-book").close();
|
||||
if (data.book) books.unshift(data.book);
|
||||
activeBookId = data.book?.id;
|
||||
expandBook(data.book.id);
|
||||
navigateToBook(data.book.id);
|
||||
renderSession(data.session, false);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
@@ -1875,19 +2048,22 @@ async function createBook() {
|
||||
}
|
||||
|
||||
async function openBook(bookId) {
|
||||
const seq = beginUiRequest();
|
||||
activeBookId = bookId;
|
||||
expandBook(bookId);
|
||||
renderBookList();
|
||||
if (lastView) renderSession(lastView, true);
|
||||
navigateToBook(bookId);
|
||||
if (lastView?.bookId === bookId) renderSession(lastView, true);
|
||||
try {
|
||||
const data = await api(`/api/books/${encodeURIComponent(bookId)}/open`, { method: "POST" });
|
||||
if (isStaleUiRequest(seq)) return;
|
||||
if (data.book) {
|
||||
const i = books.findIndex((b) => b.id === bookId);
|
||||
if (i >= 0) books[i] = { ...books[i], ...data.book };
|
||||
}
|
||||
renderSession(data.session, false);
|
||||
} catch (err) {
|
||||
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
|
||||
if (isStaleUiRequest(seq)) return;
|
||||
if (lastView?.bookId === bookId) renderSession({ ...lastView, hints: [err.message] }, false);
|
||||
else alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1899,16 +2075,13 @@ async function deleteBookById(bookId) {
|
||||
await api(`/api/books/${encodeURIComponent(bookId)}`, { method: "DELETE" });
|
||||
books = books.filter((b) => b.id !== bookId);
|
||||
playSavesByBook.delete(bookId);
|
||||
expandedBookIds.delete(bookId);
|
||||
selectedBookIds.delete(bookId);
|
||||
if (activeBookId === bookId) activePlaySaveId = null;
|
||||
hideBookMenu();
|
||||
if (activeBookId === bookId) {
|
||||
if (books.length) {
|
||||
await openBook(books[0].id);
|
||||
} else {
|
||||
renderEmpty();
|
||||
}
|
||||
const wasHere = activeBookId === bookId || sidebarNav.bookId === bookId;
|
||||
if (wasHere) {
|
||||
if (books.length) showCatalog();
|
||||
else renderEmpty();
|
||||
} else {
|
||||
renderBookList();
|
||||
}
|
||||
@@ -1952,6 +2125,21 @@ async function saveCurrentInstance() {
|
||||
}
|
||||
}
|
||||
|
||||
async function showStepArtifactDialog(stepId, name) {
|
||||
if (!sessionId || !stepId) return;
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/step-artifact?stepId=${encodeURIComponent(stepId)}`,
|
||||
);
|
||||
fillStepArtifactDialog({
|
||||
name: data.name || name,
|
||||
content: data.content,
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.message || "无法读取产物");
|
||||
}
|
||||
}
|
||||
|
||||
function showContextTraceDialog(messageId) {
|
||||
const msg = (lastView?.messages ?? []).find((m) => m.id === messageId);
|
||||
const review = lastView?.reviewArtifact;
|
||||
@@ -2102,7 +2290,13 @@ $("btn-save-instance")?.addEventListener("click", () => {
|
||||
|
||||
window.addEventListener("wa:session-updated", (e) => {
|
||||
const view = e.detail;
|
||||
if (view?.id) renderSession(view, false);
|
||||
if (!view?.id || isCatalogNav() || !sessionId) return;
|
||||
if (view.id !== sessionId) return;
|
||||
renderSession(view, false);
|
||||
});
|
||||
|
||||
window.addEventListener("wa:nav-root", () => {
|
||||
showCatalog();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
@@ -2116,7 +2310,7 @@ async function init() {
|
||||
void populateDirectorSelect();
|
||||
await loadBooks();
|
||||
if (books.length) {
|
||||
await openBook(books[0].id);
|
||||
showCatalog();
|
||||
} else {
|
||||
renderEmpty();
|
||||
openNewBookDialog();
|
||||
|
||||
+10
-2
@@ -94,6 +94,14 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
|
||||
return base;
|
||||
}
|
||||
|
||||
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
|
||||
export function isFlowPlanReview(view) {
|
||||
return (
|
||||
view?.waitingReason?.kind === "review_artifact" &&
|
||||
view?.reviewArtifact?.workerId === "design-flow"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
|
||||
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
|
||||
@@ -131,8 +139,8 @@ export function reviewComposerCopy(workerId, opts = {}) {
|
||||
taskLabel: "开场",
|
||||
taskTitle: "选定开场白",
|
||||
hint: hasQuestions
|
||||
? "同意就选定此开场;要改就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改就写意见。",
|
||||
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改开场白就写意见。",
|
||||
submitLabel: "按意见修改",
|
||||
acceptLabel: "选定此开场",
|
||||
placeholder: "修改意见…",
|
||||
|
||||
+28
-12
@@ -12,25 +12,28 @@
|
||||
/>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body data-lifecycle="design">
|
||||
<body data-lifecycle="design" data-nav-level="root">
|
||||
<div class="layout">
|
||||
<aside class="panel panel-rail" id="panel-books">
|
||||
<header class="panel-head rail-head">
|
||||
<button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="展开 / 收起侧栏" aria-label="展开或收起侧栏">‹</button>
|
||||
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="侧栏">
|
||||
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="作品">
|
||||
<div class="rail-head-row">
|
||||
<button type="button" class="rail-toggle btn-sm" id="btn-rail-toggle" title="返回作品目录" aria-label="返回作品目录">‹</button>
|
||||
<h1 class="rail-context" id="rail-context">作品</h1>
|
||||
<div class="panel-head-actions rail-head-actions">
|
||||
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
|
||||
<span class="burst-badge" id="burst-badge" hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="rail-tabs" id="rail-tabs" role="tablist" aria-label="本书侧栏" hidden>
|
||||
<button type="button" class="rail-tab active" data-rail-tab="books" role="tab" aria-selected="true" title="本书">
|
||||
<span class="rail-tab-icon" aria-hidden="true">书</span>
|
||||
<span class="rail-tab-label">作品</span>
|
||||
<span class="rail-tab-label">本书</span>
|
||||
</button>
|
||||
<button type="button" class="rail-tab" data-rail-tab="log" role="tab" aria-selected="false" title="调度">
|
||||
<span class="rail-tab-icon" aria-hidden="true">志</span>
|
||||
<span class="rail-tab-label">调度</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="panel-head-actions rail-head-actions">
|
||||
<button type="button" class="btn-sm" id="btn-new-book" title="新建">+</button>
|
||||
<span class="burst-badge" id="burst-badge" hidden></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rail-panels">
|
||||
@@ -50,9 +53,7 @@
|
||||
<div class="main-body">
|
||||
<section class="panel-feed" id="panel-feed">
|
||||
<div id="skill-picker" class="skill-picker" hidden></div>
|
||||
<div id="message-feed" class="message-feed">
|
||||
<p class="empty">正在打开作品…</p>
|
||||
</div>
|
||||
<div id="message-feed" class="message-feed"></div>
|
||||
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
|
||||
<div id="questions-card-host" class="questions-card-host" hidden></div>
|
||||
</section>
|
||||
@@ -138,6 +139,21 @@
|
||||
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
|
||||
</menu>
|
||||
|
||||
<menu id="flow-node-menu" class="context-menu" hidden>
|
||||
<button type="button" class="context-menu-item" id="flow-menu-view" data-flow-menu-action="view">查看产物</button>
|
||||
<button type="button" class="context-menu-item" id="flow-menu-reenter" data-flow-menu-action="reenter">重新进入修改</button>
|
||||
</menu>
|
||||
|
||||
<dialog id="dialog-step-artifact" class="dialog dialog-wide" aria-labelledby="step-artifact-title">
|
||||
<form method="dialog" class="context-trace-form">
|
||||
<h2 id="step-artifact-title">产物</h2>
|
||||
<div id="step-artifact-body" class="step-artifact-body"></div>
|
||||
<div class="dialog-actions">
|
||||
<button type="submit" class="btn btn-primary" value="close">关闭</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
|
||||
<form method="dialog" class="context-trace-form">
|
||||
<h2 id="context-trace-title">上下文调用日志</h2>
|
||||
|
||||
+178
@@ -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
@@ -432,6 +432,37 @@ code {
|
||||
color: var(--st-danger);
|
||||
}
|
||||
|
||||
.cap-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cap-unknown {
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
.cap-chip {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border, #ddd);
|
||||
}
|
||||
.cap-chip.cap-ok {
|
||||
color: #067647;
|
||||
background: #ecfdf3;
|
||||
border-color: #abefc6;
|
||||
}
|
||||
.cap-chip.cap-fail {
|
||||
color: #b42318;
|
||||
background: #fef3f2;
|
||||
border-color: #fecdca;
|
||||
}
|
||||
.cap-chip.cap-unk {
|
||||
color: #667085;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* —— Toast —— */
|
||||
.settings-toast {
|
||||
margin: 10px 28px 0;
|
||||
@@ -1042,7 +1073,8 @@ code {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-dialog input {
|
||||
.profile-dialog input,
|
||||
.profile-dialog select {
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
@@ -1051,12 +1083,25 @@ code {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-dialog input:focus {
|
||||
.profile-dialog select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-dialog input:focus,
|
||||
.profile-dialog select:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 3px var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.profile-dialog .field-hint {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--st-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -148,6 +148,17 @@
|
||||
Model
|
||||
<input name="model" required placeholder="deepseek-chat" />
|
||||
</label>
|
||||
<label>
|
||||
思考强度
|
||||
<select name="reasoningEffort">
|
||||
<option value="">默认(不发送)</option>
|
||||
<option value="low">low · 轻量</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high · 增强</option>
|
||||
<option value="max">max · 深度</option>
|
||||
</select>
|
||||
<span class="field-hint">写入 reasoning_effort;「探测」始终用 low,与此项无关。GLM 建议 low / high / max。</span>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
|
||||
<button type="button" id="profile-save" class="btn-secondary">仅保存</button>
|
||||
|
||||
+53
-7
@@ -150,6 +150,31 @@ function activateButtonHtml(active, id, action) {
|
||||
return `<button type="button" class="btn-activate" data-action="${action}" data-id="${id}">选用并生效</button>`;
|
||||
}
|
||||
|
||||
function capabilityBadge(caps) {
|
||||
if (!caps || !caps.testedAt) {
|
||||
return `<div class="config-card-meta cap-unknown">能力:未探测(选用前建议点「探测」)</div>`;
|
||||
}
|
||||
const bit = (label, status) => {
|
||||
const cls =
|
||||
status === "ok" ? "cap-ok" : status === "fail" ? "cap-fail" : "cap-unk";
|
||||
return `<span class="cap-chip ${cls}">${escapeHtml(label)}:${escapeHtml(status)}</span>`;
|
||||
};
|
||||
const mode =
|
||||
caps.jsonSchema === "ok"
|
||||
? "JSON Schema"
|
||||
: caps.forcedTool === "ok"
|
||||
? "强制 Tool"
|
||||
: caps.jsonObject === "ok"
|
||||
? "JSON Object"
|
||||
: "纯文本";
|
||||
return `<div class="config-card-meta cap-row">
|
||||
投递:<strong>${escapeHtml(mode)}</strong>
|
||||
${bit("schema", caps.jsonSchema)}
|
||||
${bit("tool", caps.forcedTool)}
|
||||
${bit("json", caps.jsonObject)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
profilesListEl.innerHTML = "";
|
||||
if (!state.profiles.length) {
|
||||
@@ -171,13 +196,18 @@ function renderProfiles() {
|
||||
${active ? '<span class="badge">当前</span>' : ""}
|
||||
</div>
|
||||
<div class="config-card-meta">${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}</div>
|
||||
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}</div>
|
||||
<div class="config-card-meta">Key: ${escapeHtml(maskKey(p.apiKey))}${
|
||||
p.reasoningEffort
|
||||
? ` · 思考: ${escapeHtml(p.reasoningEffort)}`
|
||||
: ""
|
||||
}</div>
|
||||
${capabilityBadge(p.capabilities)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-actions">
|
||||
${activateButtonHtml(active, p.id, "activate-profile")}
|
||||
<button type="button" data-action="edit-profile" data-id="${p.id}">编辑</button>
|
||||
<button type="button" data-action="test-profile" data-id="${p.id}">测试连接</button>
|
||||
<button type="button" data-action="probe-capabilities" data-id="${p.id}">探测</button>
|
||||
<button type="button" class="btn-danger" data-action="delete-profile" data-id="${p.id}">删除</button>
|
||||
</div>
|
||||
<div class="test-result" id="test-${p.id}"></div>`;
|
||||
@@ -367,6 +397,7 @@ function openProfileDialog(profile = null) {
|
||||
profileForm.baseUrl.value = profile?.baseUrl ?? "https://api.deepseek.com";
|
||||
profileForm.apiKey.value = profile?.apiKey ?? "";
|
||||
profileForm.model.value = profile?.model ?? "deepseek-v4-pro";
|
||||
profileForm.reasoningEffort.value = profile?.reasoningEffort ?? "";
|
||||
profileDialog.showModal();
|
||||
}
|
||||
|
||||
@@ -375,6 +406,7 @@ async function saveProfile(activate) {
|
||||
name: profileForm.name.value,
|
||||
baseUrl: profileForm.baseUrl.value,
|
||||
model: profileForm.model.value,
|
||||
reasoningEffort: profileForm.reasoningEffort.value,
|
||||
activate,
|
||||
};
|
||||
const apiKey = profileForm.apiKey.value.trim();
|
||||
@@ -457,13 +489,27 @@ profilesListEl.addEventListener("click", async (e) => {
|
||||
await activateProfile(id);
|
||||
} else if (action === "edit-profile") {
|
||||
openProfileDialog(state.profiles.find((p) => p.id === id));
|
||||
} else if (action === "test-profile") {
|
||||
} else if (action === "probe-capabilities") {
|
||||
const el = document.getElementById(`test-${id}`);
|
||||
el.textContent = "测试中…";
|
||||
el.textContent = "探测中(含连通性,会打几次短请求)…";
|
||||
el.className = "test-result";
|
||||
const result = await api(`/api/profiles/${id}/test`, { method: "POST" });
|
||||
el.textContent = result.message;
|
||||
el.className = `test-result ${result.ok ? "ok" : "fail"}`;
|
||||
const result = await api(`/api/profiles/${id}/probe-capabilities`, {
|
||||
method: "POST",
|
||||
});
|
||||
await loadAll();
|
||||
const caps = result.profile?.capabilities;
|
||||
const chatOk = caps?.chat === "ok";
|
||||
const note = !chatOk
|
||||
? `连接失败${caps?.notes?.[0] ? `:${caps.notes[0]}` : ""}`
|
||||
: result.deliveryModeLabel
|
||||
? `连接正常 · 投递将用:${result.deliveryModeLabel}`
|
||||
: "探测完成";
|
||||
const refreshed = document.getElementById(`test-${id}`);
|
||||
if (refreshed) {
|
||||
refreshed.textContent = note;
|
||||
refreshed.className = `test-result ${chatOk ? "ok" : "fail"}`;
|
||||
}
|
||||
showToast(note, !chatOk);
|
||||
} else if (action === "delete-profile") {
|
||||
if (!confirm("确定删除此 API 配置?")) return;
|
||||
await api(`/api/profiles/${id}`, { method: "DELETE" });
|
||||
|
||||
+736
-29
@@ -69,12 +69,35 @@ a.link:hover { text-decoration: underline; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail-head {
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.rail-head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 6px 8px 6px 6px;
|
||||
}
|
||||
body[data-nav-level="root"] .rail-head-row {
|
||||
padding-left: 10px;
|
||||
}
|
||||
.rail-context {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-toggle {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
@@ -87,12 +110,19 @@ a.link:hover { text-decoration: underline; }
|
||||
line-height: 1;
|
||||
}
|
||||
body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
|
||||
body[data-nav-level="root"] .rail-toggle { display: none; }
|
||||
body[data-nav-level="book"] .rail-head-actions #btn-new-book,
|
||||
body[data-nav-level="root"] .burst-badge { display: none; }
|
||||
.rail-tabs {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 2px;
|
||||
display: none;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
body[data-nav-level="book"] .rail-tabs {
|
||||
display: flex;
|
||||
}
|
||||
.rail-tabs[hidden] { display: none !important; }
|
||||
.rail-tab {
|
||||
flex: 1 1 0;
|
||||
display: inline-flex;
|
||||
@@ -102,21 +132,28 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
padding: 0 6px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rail-tab:hover { background: var(--bg); color: var(--text); }
|
||||
.rail-tab:hover { color: var(--text); }
|
||||
.rail-tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--text);
|
||||
}
|
||||
.rail-tab:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.rail-tab-icon {
|
||||
display: none;
|
||||
@@ -177,8 +214,8 @@ body.rail-collapsed .rail-toggle { transform: rotate(180deg); }
|
||||
.rail-tab-panel[hidden] { display: none !important; }
|
||||
|
||||
body.rail-collapsed .rail-tab-label,
|
||||
body.rail-collapsed .path-bar,
|
||||
body.rail-collapsed .rail-head-actions #btn-new-book,
|
||||
body.rail-collapsed .rail-context,
|
||||
body.rail-collapsed .rail-head-actions,
|
||||
body.rail-collapsed .burst-badge { display: none !important; }
|
||||
body.rail-collapsed .rail-tab-icon { display: block; }
|
||||
body.rail-collapsed .rail-head {
|
||||
@@ -187,14 +224,27 @@ body.rail-collapsed .rail-head {
|
||||
padding: 6px 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
body.rail-collapsed .rail-tabs {
|
||||
body.rail-collapsed .rail-head-row {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
body.rail-collapsed[data-nav-level="book"] .rail-tabs:not([hidden]) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
body.rail-collapsed .rail-tab {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
body.rail-collapsed .rail-tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
body.rail-collapsed .rail-panels { display: none; }
|
||||
body.rail-collapsed .panel-rail { align-items: stretch; }
|
||||
@@ -734,6 +784,39 @@ body.design-workspace .review-worker-set {
|
||||
color: var(--accent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.workspace-intent-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
max-width: 46rem;
|
||||
}
|
||||
.workspace-intent-head .workspace-surface-kicker {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.workspace-leave-step {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.workspace-leave-step:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.workspace-leave-step:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.workspace-answer-head--slim .workspace-leave-step {
|
||||
margin-left: auto;
|
||||
}
|
||||
.workspace-intent,
|
||||
.workspace-answer {
|
||||
flex: 1 1 auto;
|
||||
@@ -884,6 +967,406 @@ body.design-workspace .flow-status {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
body.design-workspace .flow-dag {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 4px 2px 8px;
|
||||
}
|
||||
body.design-workspace .flow-dag-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
body.design-workspace .flow-dag-edge {
|
||||
stroke: color-mix(in srgb, var(--border) 80%, var(--muted));
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
body.design-workspace .flow-dag-edge.is-lit {
|
||||
stroke: color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
opacity: 1;
|
||||
}
|
||||
body.design-workspace .flow-dag-arrowhead {
|
||||
fill: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
}
|
||||
body.design-workspace .flow-dag-rows {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
body.design-workspace .flow-dag-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.design-workspace .flow-dag-row-label {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
body.design-workspace .flow-dag-row-nodes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
body.design-workspace .flow-node {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: min(100%, 280px);
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
min-width: 0;
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 70%, transparent);
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
box-shadow 0.12s ease,
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
body.design-workspace .flow-node-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
body.design-workspace .flow-node.is-done-menu {
|
||||
cursor: context-menu;
|
||||
opacity: 0.92;
|
||||
}
|
||||
body.design-workspace .flow-node.is-done-menu:hover {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--border) 40%, var(--text) 20%);
|
||||
background: var(--surface);
|
||||
}
|
||||
body.design-workspace .flow-node.is-done-menu:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body.design-workspace .flow-node-menu-btn {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
body.design-workspace .flow-node.is-done-menu:hover .flow-node-menu-btn,
|
||||
body.design-workspace .flow-node.is-done-menu:focus-visible .flow-node-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.step-artifact-body {
|
||||
max-height: min(70vh, 560px);
|
||||
overflow: auto;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
body.design-workspace .flow-node[data-run="current"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
body.design-workspace .flow-node.is-locked {
|
||||
opacity: 0.55;
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable {
|
||||
cursor: pointer;
|
||||
border-color: var(--accent);
|
||||
border-width: 1.5px;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||
0 4px 12px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:hover {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
|
||||
0 2px 6px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body.design-workspace .flow-action {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable:hover .flow-action {
|
||||
color: color-mix(in srgb, var(--accent) 80%, #000);
|
||||
}
|
||||
body.design-workspace .flow-node--prototype {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--bg));
|
||||
}
|
||||
body.design-workspace .flow-node--prototype.is-selectable {
|
||||
border-style: dashed;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
body.design-workspace .flow-node--prototype.is-selectable:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
body.design-workspace .flow-occ-prototype {
|
||||
color: var(--accent);
|
||||
}
|
||||
body.design-workspace .flow-family {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: min(100%, 280px);
|
||||
min-width: 0;
|
||||
}
|
||||
body.design-workspace .flow-family > .flow-node {
|
||||
width: 100%;
|
||||
}
|
||||
body.design-workspace .flow-family-spawns {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 8px 0 0 22px;
|
||||
list-style: none;
|
||||
}
|
||||
body.design-workspace .flow-family-spawns::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 14px;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
}
|
||||
body.design-workspace .flow-family-spawn {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
body.design-workspace .flow-family-spawn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
}
|
||||
body.design-workspace .flow-node--spawn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
background: var(--surface);
|
||||
}
|
||||
body.design-workspace .flow-node--spawn .flow-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
body.design-workspace .flow-node--spawn .flow-params,
|
||||
body.design-workspace .flow-node--spawn .flow-params-missing {
|
||||
font-size: 11px;
|
||||
}
|
||||
body.design-workspace .flow-node--spawn.is-selectable {
|
||||
border-width: 1px;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-node--spawn.is-selectable:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
body.design-workspace .flow-spawn-meta {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
body.design-workspace .flow-spawn-meta .flow-action {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
body.design-workspace .flow-spawn-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
body.design-workspace .flow-origin {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
body.design-workspace .flow-origin-rule {
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
body.design-workspace .flow-dag-edge--belong {
|
||||
stroke-dasharray: 3 4;
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
body.design-workspace .flow-dag-arrowhead--belong {
|
||||
fill: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
}
|
||||
body.design-workspace .flow-suggestion-inline {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
body.design-workspace .flow-suggestions {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
body.design-workspace .flow-suggestions-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
body.design-workspace .flow-suggestion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
body.design-workspace .flow-suggestion:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
body.design-workspace .flow-suggestion-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
body.design-workspace .flow-suggestion-text {
|
||||
color: var(--muted);
|
||||
}
|
||||
body.design-workspace .flow-node .flow-run {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
body.design-workspace .flow-node[data-run="done"] .flow-run {
|
||||
color: #15803d;
|
||||
}
|
||||
body.design-workspace .flow-node.is-selectable .flow-run {
|
||||
color: var(--accent);
|
||||
}
|
||||
body.design-workspace .flow-node .flow-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
body.design-workspace .flow-title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
body.design-workspace .flow-node .flow-params,
|
||||
body.design-workspace .flow-node .flow-params-missing {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
body.design-workspace .flow-spawns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
body.design-workspace .flow-spawn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
body.design-workspace .flow-spawn.is-ready {
|
||||
cursor: pointer;
|
||||
border-style: solid;
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
body.design-workspace .flow-spawn.is-ready:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
|
||||
}
|
||||
body.design-workspace .flow-spawn.is-ready:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
body.design-workspace .flow-spawn.is-blocked {
|
||||
opacity: 0.65;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
body.design-workspace .flow-dag-row-nodes {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.design-workspace .flow-node,
|
||||
body.design-workspace .flow-family {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
body.design-workspace .flow-steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1004,8 +1487,20 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.btn:focus,
|
||||
.btn-sm:focus {
|
||||
outline: none;
|
||||
}
|
||||
.btn:focus-visible,
|
||||
.btn-sm:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn-sm { padding: 4px 8px; font-size: 12px; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn-primary:focus-visible {
|
||||
outline-color: color-mix(in srgb, var(--accent) 50%, #fff);
|
||||
}
|
||||
.btn-danger { color: var(--danger); border-color: #fecaca; }
|
||||
.btn:disabled, .btn-sm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -1950,7 +2445,7 @@ body.design-workspace .af-mosaic {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
}
|
||||
|
||||
/* 美学验收:左契约 / 右诊断(整体样式约定) */
|
||||
/* 美学验收:左契约 / 右诊断 */
|
||||
.af-split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2002,13 +2497,13 @@ body.design-workspace .af-mosaic {
|
||||
background: var(--bg);
|
||||
}
|
||||
.af-block-head {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.af-block-title {
|
||||
margin: 0 0 4px;
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
@@ -2032,20 +2527,80 @@ body.design-workspace .af-mosaic {
|
||||
.af-block-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.af-block--product .af-block-body .artifact-kernel .artifact-prose,
|
||||
.af-block--product .af-block-body .artifact-prose {
|
||||
font-family: var(--font-read);
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
max-width: 42rem;
|
||||
|
||||
/* 美学卡:阅读层级 — 内核最大,范式次之,右栏更密更沉 */
|
||||
.af-split--aesthetics .af-split-product {
|
||||
gap: 22px;
|
||||
}
|
||||
.af-block--eval .af-block-title {
|
||||
.af-split--aesthetics .af-block--product .af-block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.af-split--aesthetics .af-block--product .af-block-head {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.af-split--aesthetics .artifact-kernel.qcard-assessment,
|
||||
.af-split--aesthetics .artifact-kernel {
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--surface) 88%, var(--bg));
|
||||
}
|
||||
.af-split--aesthetics .artifact-kernel .artifact-aside-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.af-split--aesthetics .artifact-kernel .artifact-prose,
|
||||
.af-split--aesthetics .artifact-kernel .artifact-inline {
|
||||
font-family: var(--font-read);
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.af-split--aesthetics .af-block--product .af-group-title,
|
||||
.af-split--aesthetics .af-block--product .af-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.af-split--aesthetics .af-block--product .af-stack,
|
||||
.af-split--aesthetics .af-block--product .af-def-list {
|
||||
gap: 12px;
|
||||
}
|
||||
.af-split--aesthetics .af-block--product .af-def-v,
|
||||
.af-split--aesthetics .af-block--product .artifact-prose {
|
||||
font-family: var(--font-read);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.65;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.af-split--aesthetics .af-split-eval {
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.af-block--extra .af-block-title {
|
||||
.af-split--aesthetics .af-block--eval .af-block-title,
|
||||
.af-split--aesthetics .af-block--extra .af-block-title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
}
|
||||
.af-split--aesthetics .af-block--eval .af-block-body,
|
||||
.af-split--aesthetics .af-block--extra .af-block-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--text) 92%, var(--muted));
|
||||
}
|
||||
.af-split--aesthetics .af-dim-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.af-split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2368,6 +2923,145 @@ body.design-workspace .af-mosaic {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 美学纲领:主栏契约 / 辅栏诊断(无第二层盒子) */
|
||||
.skill-view.skill-view-aesthetics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(15.5rem, 0.88fr);
|
||||
gap: 8px 36px;
|
||||
align-items: start;
|
||||
}
|
||||
.skill-view-aesthetics.af-aes--solo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.af-aes-main,
|
||||
.af-aes-diag {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.af-aes-diag {
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.af-aes-kernel .artifact-aside-label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.af-aes-kernel .artifact-prose,
|
||||
.af-aes-kernel .artifact-inline {
|
||||
font-family: var(--font-read);
|
||||
font-size: 15.5px;
|
||||
line-height: 1.78;
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.af-aes-paradigm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.af-aes-main > .skill-card-section > h4,
|
||||
.af-aes-paradigm > .skill-card-section > h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.af-aes-diag > .skill-card-section > h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.af-aes-diag .af-dim-stack { gap: 10px; }
|
||||
.af-aes-diag .af-dim { padding-bottom: 10px; }
|
||||
.af-aes-diag .af-dim-title { font-size: 13px; }
|
||||
.af-aes-twist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.af-aes-twist-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.af-aes-twist-k {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding-top: 2px;
|
||||
}
|
||||
.af-aes-twist-v {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
.af-aes-twist-infer {
|
||||
margin: 2px 0 0;
|
||||
font-family: var(--font-read);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
.af-dim-row.is-pending .af-dim-k {
|
||||
color: #a16207;
|
||||
font-weight: 650;
|
||||
}
|
||||
.af-dim-row.is-pending .af-dim-v { color: #854d0e; }
|
||||
@media (max-width: 860px) {
|
||||
.skill-view.skill-view-aesthetics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.af-aes-diag {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
.artifact-compact-body .skill-view.skill-view-aesthetics {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.artifact-compact-body .af-aes-diag {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ws-focus-body .skill-view.skill-view-aesthetics {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(15.5rem, 0.88fr);
|
||||
}
|
||||
.ws-focus-body .af-aes-diag {
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--border);
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.ws-focus-body .skill-view.skill-view-aesthetics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ws-focus-body .af-aes-diag {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.skill-card-section > h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
@@ -2395,6 +3089,14 @@ body.design-workspace .af-mosaic {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.af-aes-main .skill-kv-row,
|
||||
.af-aes-paradigm .skill-kv-row {
|
||||
grid-template-columns: minmax(4.75rem, 7.25rem) 1fr;
|
||||
}
|
||||
.af-aes-main .skill-kv-val,
|
||||
.af-aes-paradigm .skill-kv-val {
|
||||
line-height: 1.55;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.skill-kv-row { grid-template-columns: 1fr; gap: 2px; }
|
||||
}
|
||||
@@ -3688,6 +4390,11 @@ body.design-workspace .af-mosaic {
|
||||
}
|
||||
.context-menu-item[hidden] { display: none; }
|
||||
.context-menu-item:hover { background: var(--bg); }
|
||||
.context-menu-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.context-menu-item:disabled:hover { background: transparent; }
|
||||
.context-menu-separator-before { border-top: 1px solid var(--border); }
|
||||
.context-menu-danger { color: var(--danger); }
|
||||
.context-menu-danger:hover { background: #fef2f2; }
|
||||
@@ -3976,7 +4683,7 @@ body.design-workspace .af-mosaic {
|
||||
.layout { grid-template-columns: var(--books-w-collapsed) 1fr; }
|
||||
body:not(.rail-collapsed) { --books-w: var(--books-w-collapsed); }
|
||||
body:not(.rail-collapsed) .rail-tab-label,
|
||||
body:not(.rail-collapsed) .path-bar { display: none !important; }
|
||||
body:not(.rail-collapsed) .rail-context { display: none !important; }
|
||||
body:not(.rail-collapsed) .rail-panels { display: none; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user