diff --git a/docs/briefs/artifact-card-coverage.md b/docs/briefs/artifact-card-coverage.md index 8790495..ab559fa 100644 --- a/docs/briefs/artifact-card-coverage.md +++ b/docs/briefs/artifact-card-coverage.md @@ -4,7 +4,7 @@ | 产物/技能 | 美化程度 | 渲染入口 | |-----------|----------|----------| -| 美学纲领与交互范式 | **左右分栏**:左契约(美学纲领+交互范式)/ 右诊断(设定逻辑) | `renderSpecialtyBodyHtml` → aesthetics | +| 美学纲领与交互范式 | **左右分栏**(左契约可读 / 右诊断密);无工程 banner | `af-split--aesthetics` + `renderAestheticsNodeHtml` | | 实现机制 | **专用卡**(支撑点) | `renderMechanismBodyHtml` | | 舞台骨架 | **专用卡** | `renderWorldBlueprintBodyHtml` | | 生成规则 | **专用卡** | `renderGenerationRulesBodyHtml` | diff --git a/docs/briefs/capability-authoring-brief.md b/docs/briefs/capability-authoring-brief.md index 12ece1a..7f96bb5 100644 --- a/docs/briefs/capability-authoring-brief.md +++ b/docs/briefs/capability-authoring-brief.md @@ -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 已合并技能(勿拆回) diff --git a/docs/context-fragment-design.md b/docs/context-fragment-design.md index 12d66a8..506865f 100644 --- a/docs/context-fragment-design.md +++ b/docs/context-fragment-design.md @@ -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`(扁平序) diff --git a/docs/creation-playbook.md b/docs/creation-playbook.md index f8f5fb9..e932312 100644 --- a/docs/creation-playbook.md +++ b/docs/creation-playbook.md @@ -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 兜底落库 diff --git a/docs/ui-glossary.md b/docs/ui-glossary.md index 4b08611..021726c 100644 --- a/docs/ui-glossary.md +++ b/docs/ui-glossary.md @@ -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. 禁止出现在用户主路径上的写法 diff --git a/docs/world-simulator-modules.md b/docs/world-simulator-modules.md index d1cf0cf..74a0d92 100644 --- a/docs/world-simulator-modules.md +++ b/docs/world-simulator-modules.md @@ -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 diff --git a/skills/dialogue/world-simulator/README.md b/skills/dialogue/world-simulator/README.md index ac9019f..60d4579 100644 --- a/skills/dialogue/world-simulator/README.md +++ b/skills/dialogue/world-simulator/README.md @@ -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 ``` diff --git a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md index 06c2a3f..c375a59 100644 --- a/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md +++ b/skills/dialogue/world-simulator/modules/aesthetics-interaction/prompt.md @@ -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 表 - 追问空洞「你想要什么体验?」无选项 diff --git a/skills/dialogue/world-simulator/modules/catalog.yaml b/skills/dialogue/world-simulator/modules/catalog.yaml index bb0409f..b8d4d51 100644 --- a/skills/dialogue/world-simulator/modules/catalog.yaml +++ b/skills/dialogue/world-simulator/modules/catalog.yaml @@ -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: 叙事指南与故事推进 diff --git a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md index be70547..cf9595d 100644 --- a/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md +++ b/skills/dialogue/world-simulator/modules/concrete-instances/prompt.md @@ -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。 ``` diff --git a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md index 5a3311c..0fa3ce5 100644 --- a/skills/dialogue/world-simulator/modules/generation-rules/prompt.md +++ b/skills/dialogue/world-simulator/modules/generation-rules/prompt.md @@ -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 整份塞进池当最终实例。 ``` diff --git a/skills/dialogue/world-simulator/orchestrator.md b/skills/dialogue/world-simulator/orchestrator.md index 29e4cee..9a88ca5 100644 --- a/skills/dialogue/world-simulator/orchestrator.md +++ b/skills/dialogue/world-simulator/orchestrator.md @@ -46,7 +46,7 @@ uiPrompt: | 你只负责读取 `设计.创作流程` DAG,并选择下一个创作节点: -- 没有 DAG 或 DAG 需要追加节点:`design-flow` -- DAG 已给出下一个可执行节点:`design-step` +- 没有 DAG、需要追加节点,或用户要求补前序节点(即使下一步已是收口):`design-flow` +- DAG 已给出下一个可执行节点、且用户没有要求改排:`design-step` 节点如何执行、何时等待、验收、返工、结束和运行状态变化均由 Runtime 与节点自身处理。 diff --git a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md index ffdac21..fda9c8b 100644 --- a/skills/dialogue/world-simulator/workers/design-flow/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-flow/SKILL.md @@ -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 是否都在? diff --git a/skills/dialogue/world-simulator/workers/design-step/SKILL.md b/skills/dialogue/world-simulator/workers/design-step/SKILL.md index 981d369..43a68b2 100644 --- a/skills/dialogue/world-simulator/workers/design-step/SKILL.md +++ b/skills/dialogue/world-simulator/workers/design-step/SKILL.md @@ -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: - 〔先验产物〕规划为空:先在本步钉「写什么」,再填产物。不要退回编排器补参。 - 普通节点【本步参数】缺必填项或与目录不符:停止产出,提示返回流程编排补参。 -需要追加「再来一次」或回头修改其它步时,由编排器再调流程编排。 +需要追加「再来一次」时,由编排器再调流程编排。已完成节点要改:用户自己在图上点该节点,不要让编排器加修订步。 diff --git a/src/config/api-profiles.ts b/src/config/api-profiles.ts index 0168474..ecb47ba 100644 --- a/src/config/api-profiles.ts +++ b/src/config/api-profiles.ts @@ -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>, + input: Partial< + Pick + >, ): 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 { + 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 : "连接失败", - }; - } -} diff --git a/src/config/env.ts b/src/config/env.ts index 040bf5f..1f171d9 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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 { diff --git a/src/llm/capabilities.ts b/src/llm/capabilities.ts new file mode 100644 index 0000000..db735c6 --- /dev/null +++ b/src/llm/capabilities.ts @@ -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; + 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, +): 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, +): Promise<{ ok: boolean; status: number; json: unknown; text: string }> { + const withEffort: Record = { + ...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(); + if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) { + return first; + } + return postOnce(config, retryBody); +} + +function assistantMessage(json: unknown): Record | 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; +} + +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 { + 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; +} diff --git a/src/llm/client.ts b/src/llm/client.ts index 6a4fcee..b69a45d 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -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; + /** 部分供应商支持的严格参数约束 */ + strict?: boolean; }; }; @@ -57,8 +66,16 @@ export type CompleteResult = { model?: string; }; +export type JsonSchemaFormat = { + name: string; + strict?: boolean; + schema: Record; +}; + 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; }; -function buildRequestBody( +export function buildRequestBody( config: LlmConfig, messages: ChatMessage[], options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean }, ): Record { - const gen = options?.generation ?? {}; const body: Record = { 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 | undefined): { async function llmFetch( url: string, apiKey: string, - body: unknown, + body: Record, options?: CompleteOptions, ): Promise { - 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 { diff --git a/src/llm/generation-compat.ts b/src/llm/generation-compat.ts new file mode 100644 index 0000000..8c6f2d8 --- /dev/null +++ b/src/llm/generation-compat.ts @@ -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 = { + 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, + 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, + field: string, + tried: Set, +): 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) => Promise, + initialBody: Record, +): Promise { + let body = initialBody; + const tried = new Set(); + 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 }); +} diff --git a/src/llm/structured-complete.ts b/src/llm/structured-complete.ts new file mode 100644 index 0000000..5820e0d --- /dev/null +++ b/src/llm/structured-complete.ts @@ -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; + /** 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, + 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 { + 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 }; +} diff --git a/src/log.ts b/src/log.ts index a53a051..2288e1c 100644 --- a/src/log.ts +++ b/src/log.ts @@ -46,6 +46,7 @@ const WAIT_LABEL: Record = { worker_questions: "执行单元提问", revision: "修订", next_intent: "下一步意向", + pick_creation_step: "点选节点", }; const EVENT_LABEL: Record = { @@ -56,9 +57,14 @@ const EVENT_LABEL: Record = { 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 = { emit_message: "发消息", propose_next_creation_step: "提案下一步", seal_creation_opening: "收口开场白", + unseal_creation_opening: "解开开场收口", run_play_turn: "开游玩回合", continue_play_turn: "续游玩管线", }; diff --git a/src/main-agent/main-agent.ts b/src/main-agent/main-agent.ts index 5af7d41..80a8d30 100644 --- a/src/main-agent/main-agent.ts +++ b/src/main-agent/main-agent.ts @@ -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 { diff --git a/src/main-agent/tool-loop.ts b/src/main-agent/tool-loop.ts index 700d3fe..4f94f9c 100644 --- a/src/main-agent/tool-loop.ts +++ b/src/main-agent/tool-loop.ts @@ -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( diff --git a/src/parse/json-doc.ts b/src/parse/json-doc.ts new file mode 100644 index 0000000..2c288cc --- /dev/null +++ b/src/parse/json-doc.ts @@ -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(/(? 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; + 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; +} diff --git a/src/runtime/orchestrator.ts b/src/runtime/orchestrator.ts index 1f7b00c..ebd9729 100644 --- a/src/runtime/orchestrator.ts +++ b/src/runtime/orchestrator.ts @@ -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; } } diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index fb6d2ef..fe4d00f 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -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"; diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index 316fda6..c16c80d 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -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 { + 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 { + 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 { const effect: Extract = { @@ -595,7 +671,7 @@ export class PhaseRuntime { return this.session; } - /** 用户拒绝 pendingDecision,回到总管 */ + /** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */ async rejectStep(reason?: string): Promise { const id = this.session.pendingDecision?.id; if (!id) throw new Error("当前没有待拒绝的决策"); @@ -606,6 +682,180 @@ export class PhaseRuntime { return this.session; } + /** + * 离开当前技能步,回到分层图。 + * 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。 + */ + async leaveCreationStep(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + 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 { + 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 { 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 { + 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 { + 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 { 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 或模式读取正文 */ diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index be29a3f..94be5d2 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -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, diff --git a/src/server/display-labels.ts b/src/server/display-labels.ts index 1b53926..7ef0dc2 100644 --- a/src/server/display-labels.ts +++ b/src/server/display-labels.ts @@ -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: "修改意见…", diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 23dc4a5..4fd6cae 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -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 { + 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 { + 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, + reenter?: boolean, + ): Promise { + 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 { + 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 { + 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 { 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 { + 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 { + 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 = {}; + 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")) { diff --git a/src/server/settings-handlers.ts b/src/server/settings-handlers.ts index cbb463a..bf91932 100644 --- a/src/server/settings-handlers.ts +++ b/src/server/settings-handlers.ts @@ -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 : "更新失败", diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 3bfdbe0..527e307 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -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; 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; diff --git a/src/skills/context-fragment.ts b/src/skills/context-fragment.ts index 8118e9f..75fe5b6 100644 --- a/src/skills/context-fragment.ts +++ b/src/skills/context-fragment.ts @@ -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 | 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; - } - } catch { - return undefined; + const parsed = tryParseJsonDoc(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + row = parsed as Record; } } else if (raw && typeof raw === "object" && !Array.isArray(raw)) { row = raw as Record; @@ -278,14 +273,9 @@ export function extractFragmentAskSidecar(raw: unknown): { } { let row: Record | 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; - } - } catch { - return { questions: [], assessment: "" }; + const parsed = tryParseJsonDoc(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + row = parsed as Record; } } else if (raw && typeof raw === "object" && !Array.isArray(raw)) { row = raw as Record; diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index 47b6113..7b7aa95 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -10,11 +10,14 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; +import { tryParseJsonDoc } from "../parse/json-doc.js"; export const CREATION_FLOW_TAG = "设计.创作流程"; export const CREATION_CURRENT_STEP_TAG = "创作.当前步骤"; /** 确认开干前:待确认的下一步快照(JSON) */ export const CREATION_PROPOSED_STEP_TAG = "创作.待确认步骤"; +/** 用户点已完成节点重进:本步继承既有产物("1") */ +export const CREATION_INHERIT_EXISTING_TAG = "创作.继承修改"; /** 用户手动选定的初始配方(存 recipe id,或 JSON {id,name}) */ export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方"; @@ -22,6 +25,7 @@ export const CREATION_SELECTED_RECIPE_TAG = "创作.选用配方"; export const PROGRESS_POINTER_TAGS = new Set([ CREATION_CURRENT_STEP_TAG, CREATION_PROPOSED_STEP_TAG, + CREATION_INHERIT_EXISTING_TAG, CREATION_SELECTED_RECIPE_TAG, "创作.当前单位", "创作.已验收单位", @@ -53,9 +57,20 @@ const DEFAULT_SKILLS_ROOT = path.resolve( "../../skills", ); -/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕:编排器规划的产物内容(可空),执行时注入为【规划产物】。 */ +/** 步骤调用参数。普通节点:编排期钉死、执行只读。〔先验产物〕实例步:步内钉「写什么」,执行期写入 params。 */ export type CreationFlowStepParams = Record; +/** 原型 = 图上可增殖槽位;实例 = 点原型或历史 spawn 出的可执行步。 */ +export type CreationFlowStepRole = "prototype" | "instance"; + +/** 规划层建议追加的原型节点(尚未编入 steps 时展示在图上)。 */ +export type CreationFlowPrototypeSuggestion = { + name: string; + /** 建议生成什么 / 为何需要此原型 */ + suggestion: string; + depends_on?: string[]; +}; + /** * 节点特性。缺省 = 普通执行步。后续会加更多 kind。 * prior-artifact = 先验产物:须先定「写什么」(生成规则、具体实例);编排器可提前规划,步内再钉/修订。 @@ -98,10 +113,19 @@ export type CreationFlowStep = { name: string; /** 依赖的其它步骤 id(旧稿若 name 唯一也可写 name) */ depends_on: string[]; + /** + * prototype = 科技树槽位,点进去增殖实例;instance = 可执行步。 + * 缺省 = 普通一次性节点。 + */ + role?: CreationFlowStepRole; + /** role=instance:从哪个原型增殖出来 */ + from?: string; + /** role=prototype:规划层对本槽位「建议生成什么」的短提示(执行时在步内钉细) */ + suggestion?: string; /** * 本步调用参数。 * 普通节点:有必填声明时须在进执行前钉齐。 - * 〔先验产物〕:编排器规划的产物内容(可空);执行注入【规划产物】,步内可修订。 + * 〔先验产物〕实例:步内钉「写什么」,不写回原型。 */ params?: CreationFlowStepParams; /** 缺省 fresh。revise = 继承旧产物修改,不是再生成一条。 */ @@ -121,6 +145,8 @@ export type CreationFlow = { * closed = 不再扩步(可走收成)。缺省按 closed(兼容旧固定 DAG)。 */ status?: CreationFlowStatus; + /** 规划层建议追加、尚未编入 steps 的原型节点 */ + suggestions?: CreationFlowPrototypeSuggestion[]; steps: CreationFlowStep[]; }; @@ -149,7 +175,8 @@ export type ModuleCatalogEntry = { */ repeatable?: boolean; /** - * 可选:创作终节点。选定后程序收口并保存,其后不要再追加步骤。 + * 可选:创作终节点。选定后程序收口并保存。 + * 尚未选定前,用户要补前序节点时应插在本步之前,不要当成「已经不能再编排」。 */ closer?: boolean; /** @@ -235,14 +262,28 @@ export type CreationFlowValidation = { /** 工作流计划节点相对执行进度(给人看的三态) */ export type FlowStepRunState = "done" | "current" | "pending"; +/** 可反复能力在图上的「再开一条」入口 */ +export type CreationFlowSpawnView = { + name: string; + ready: boolean; + blockedReason?: string; +}; + export type CreationFlowUserView = { brief?: string; status?: CreationFlowStatus; + /** 规划层建议追加的原型(尚未在 steps 里) */ + suggestions?: CreationFlowPrototypeSuggestion[]; steps: Array<{ order: number; id: string; name: string; depends_on: string[]; + role?: CreationFlowStepRole; + from?: string; + suggestion?: string; + /** 图上短标题:增殖的是哪条规则 / 哪批实例(不等于技能名) */ + title?: string; /** 同能力第几次(>1 时 UI 可标「再来」;回头修改不计入「第 N 次新建」) */ occurrence?: number; /** fresh=从零新建;revise=回头修改 */ @@ -252,35 +293,133 @@ export type CreationFlowUserView = { /** 目录里的短声明(有则展示) */ declaration?: string; repeatable?: boolean; + /** 收口节点(开场白):点进去做这一条,不增殖 */ + closer?: boolean; /** 本步调用参数(普通节点编排钉死;先验产物=规划内容) */ params?: CreationFlowStepParams; /** 参数缺必填项时的提示(给人看);先验产物不拦执行,不出现此项 */ paramsMissing?: string[]; /** 节点特性(如先验产物) */ kind?: ModuleNodeKind; + /** 依赖层级(0 = 无前置);图按列/行排 */ + layer: number; + /** 未验收且 depends_on 均已验收 */ + ready: boolean; + /** 图上可点进去做这一条(ready 且未完成) */ + selectable: boolean; + /** 挡住本步的未完成依赖 id */ + blockedBy?: string[]; /** 已执行 / 将要执行 / 未执行 */ runState: FlowStepRunState; + /** 本步产物 tag(已完成节点菜单「查看产物」用) */ + artifactTag?: string; + /** 黑板已有该步产物 */ + hasArtifact?: boolean; }>; parseError?: string; }; +export function parseCreationFlowStepRole( + raw: unknown, +): CreationFlowStepRole | undefined { + if (raw === "prototype" || raw === "instance") return raw; + return undefined; +} + +export function isPrototypeStep( + step: Pick | null | undefined, +): boolean { + return step?.role === "prototype"; +} + +export function isInstanceStep( + step: Pick | null | undefined, +): boolean { + return step?.role === "instance"; +} + +/** 图上可点进去跑 design-step 的步(非原型槽位) */ +export function isExecutableStep(step: CreationFlowStep): boolean { + return !isPrototypeStep(step); +} + +function parsePrototypeSuggestions(raw: unknown): CreationFlowPrototypeSuggestion[] | undefined { + if (!Array.isArray(raw) || raw.length === 0) return undefined; + const out: CreationFlowPrototypeSuggestion[] = []; + for (const item of raw) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const row = item as Record; + const name = typeof row.name === "string" ? row.name.trim() : ""; + const suggestion = + typeof row.suggestion === "string" + ? row.suggestion.trim() + : typeof row.hint === "string" + ? row.hint.trim() + : ""; + if (!name || !suggestion) continue; + const depsRaw = row.depends_on ?? row.dependsOn ?? []; + const depends_on = Array.isArray(depsRaw) + ? depsRaw.map((d) => String(d).trim()).filter(Boolean) + : undefined; + out.push({ + name, + suggestion, + ...(depends_on?.length ? { depends_on } : {}), + }); + } + return out.length ? out : undefined; +} + +/** 依赖是否满足:指向原型 = 至少有一条已验收实例;否则 = 该步已验收。 */ +export function isCreationDependencySatisfied( + flow: CreationFlow, + depRef: string, + acceptedStepIds: readonly string[], +): boolean { + const depStep = findStepByRef(flow, depRef); + if (!depStep) return false; + if (isPrototypeStep(depStep)) { + return flow.steps.some( + (s) => + isInstanceStep(s) && + s.from === depStep.id && + isStepAccepted(s, acceptedStepIds), + ); + } + return isStepAccepted(depStep, acceptedStepIds); +} + +export function isPrototypeSelectable( + flow: CreationFlow, + step: CreationFlowStep, + acceptedStepIds: readonly string[], +): boolean { + if (!isPrototypeStep(step)) return false; + return step.depends_on.every((dep) => + isCreationDependencySatisfied(flow, dep, acceptedStepIds), + ); +} + +/** 根对象是创作流程 DAG(有 steps,且至少一步带 name) */ +export function looksLikeCreationFlowDoc(doc: unknown): boolean { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false; + const steps = (doc as { steps?: unknown }).steps; + if (!Array.isArray(steps) || steps.length === 0) return false; + return steps.some( + (item) => + item && + typeof item === "object" && + !Array.isArray(item) && + typeof (item as { name?: unknown }).name === "string" && + String((item as { name: string }).name).trim().length > 0, + ); +} + /** 从任意正文抽取 JSON 对象 */ export function extractJsonObject(raw: string): unknown | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed); - } catch { - /* try slice */ - } - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start >= 0 && end > start) { - try { - return JSON.parse(trimmed.slice(start, end + 1)); - } catch { - return null; - } + const parsed = tryParseJsonDoc(raw); + if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; } return null; } @@ -338,18 +477,22 @@ export function formatStepParamsForPrompt( } const PRIOR_ARTIFACT_CONTEXT_TITLE = - "## 【规划产物】编排器预先为本步规划的产物内容(先验;缺则步内钉;步内可修订)"; + "## 【本步对象】须在本步与用户钉「写什么」再产出(不依赖编排层预填)"; -/** 〔先验产物〕注入块:规划可空,身份写在片段自身。 */ +/** 〔先验产物〕实例步注入块:params 仅来自本步已钉内容;原型 suggestion 作参考。 */ export function formatPriorArtifactContext( params: CreationFlowStepParams | null | undefined, + prototypeSuggestion?: string | null, ): string { const body = formatStepParamsForPrompt(params); + const hint = prototypeSuggestion?.trim() + ? `\n\n【原型建议】${prototypeSuggestion.trim()}(规划层提示,可在步内修订或忽略)` + : ""; const note = !params || Object.keys(params).length === 0 - ? "编排器尚未规划本步具体写什么。先在本步与用户钉「写什么」,再填产物。" - : "以上为先验规划,不是锁死合同。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。"; - return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}`; + ? "尚未钉本步具体写什么。先与用户确认对象/范围,再填产物。" + : "以上为本步已钉对象。用户改对象或范围时,以本步最新认定为准,并重钉对应英文 id。"; + return `${PRIOR_ARTIFACT_CONTEXT_TITLE}\n\n${body}\n\n${note}${hint}`; } const REVISE_MODE_ALIASES = new Set([ @@ -389,6 +532,65 @@ export function isReviseStep( return step?.mode === "revise"; } +export function isInheritExistingFlag(raw: unknown): boolean { + if (raw === true) return true; + if (typeof raw !== "string") return false; + const key = raw.trim().toLowerCase(); + return key === "1" || key === "true" || key === "revise" || key === "回头修改"; +} + +/** + * 回头修改不是 DAG 节点:丢掉 mode=revise 步,并把依赖改回原稿 id。 + * 旧会话里编排器排过的修订步靠这一层从执行图上拿掉。 + */ +export function stripReviseSteps(flow: CreationFlow): CreationFlow { + const idMap = new Map(); + for (const step of flow.steps) { + if (!isReviseStep(step)) continue; + const origin = step.revises + ? flow.steps.find((s) => s.id === step.revises || s.name === step.revises) + : undefined; + idMap.set(step.id, origin?.id || step.revises || step.name); + } + if (idMap.size === 0) return flow; + + const resolve = (ref: string): string => { + let cur = ref.trim(); + const seen = new Set(); + while (idMap.has(cur) && !seen.has(cur)) { + seen.add(cur); + cur = idMap.get(cur)!; + } + return cur; + }; + + const steps = flow.steps + .filter((s) => !isReviseStep(s)) + .map((s) => { + const depends_on = [...new Set(s.depends_on.map(resolve))].filter( + (d) => d && d !== s.id, + ); + if ( + depends_on.length === s.depends_on.length && + depends_on.every((d, i) => d === s.depends_on[i]) + ) { + return s; + } + return { ...s, depends_on }; + }); + + const suggestions = flow.suggestions?.map((sg) => { + if (!sg.depends_on?.length) return sg; + return { ...sg, depends_on: [...new Set(sg.depends_on.map(resolve))] }; + }); + + return { + ...flow, + steps, + ...(suggestions ? { suggestions } : {}), + }; +} + function parseStepRevisesRef(raw: Record): string | undefined { for (const key of ["revises", "revise_of", "revises_id"] as const) { const v = raw[key]; @@ -401,6 +603,9 @@ type DraftCreationFlowStep = { id?: string; name: string; depends_on: string[]; + role?: CreationFlowStepRole; + from?: string; + suggestion?: string; params?: CreationFlowStepParams; mode?: CreationFlowStepMode; revises?: string; @@ -485,6 +690,9 @@ export function ensureCreationFlowStepIds( id, name, depends_on: raw.depends_on.map((d) => d.trim()).filter(Boolean), + ...(raw.role ? { role: raw.role } : {}), + ...(raw.from ? { from: raw.from } : {}), + ...(raw.suggestion ? { suggestion: raw.suggestion } : {}), ...(raw.params ? { params: raw.params } : {}), ...withStepModeFields(raw), }); @@ -492,6 +700,66 @@ export function ensureCreationFlowStepIds( return normalizeReviseSteps(out); } +/** + * 补齐 / 规范化原型与实例角色(旧稿 repeatable 无 role → 升为 prototype + instances)。 + */ +export function normalizeFlowStepRoles( + steps: CreationFlowStep[], + catalog: ModuleCatalog | null | undefined, +): CreationFlowStep[] { + const repeatableNames = new Set( + (catalog?.modules ?? []) + .filter((m) => m.repeatable === true) + .map((m) => m.name), + ); + const prototypeIdByName = new Map(); + for (const step of steps) { + if (isPrototypeStep(step)) prototypeIdByName.set(step.name, step.id); + } + + const out: CreationFlowStep[] = []; + for (const step of steps) { + if (step.role) { + if (isPrototypeStep(step)) { + const { params: _drop, ...rest } = step; + out.push(rest); + } else { + out.push(step); + } + continue; + } + if (!repeatableNames.has(step.name) || isReviseStep(step)) { + out.push(step); + continue; + } + if (!prototypeIdByName.has(step.name)) { + prototypeIdByName.set(step.name, step.id); + const hint = + step.params && Object.keys(step.params).length + ? Object.entries(step.params) + .map( + ([k, v]) => + `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`, + ) + .join(";") + : undefined; + const { params: _drop, ...rest } = step; + out.push({ + ...rest, + role: "prototype", + ...(hint && !step.suggestion ? { suggestion: hint } : {}), + }); + } else { + out.push({ + ...step, + role: "instance", + from: prototypeIdByName.get(step.name)!, + }); + } + } + return out; +} + /** 按 id 或(唯一)name 解析步骤引用 */ export function findStepByRef( flow: CreationFlow, @@ -569,10 +837,20 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow const mode = parseCreationFlowStepMode(s.mode) ?? parseCreationFlowStepMode(s.intent); const revises = parseStepRevisesRef(s); + const role = parseCreationFlowStepRole(s.role); + const from = + typeof s.from === "string" && s.from.trim() ? s.from.trim() : undefined; + const suggestion = + typeof s.suggestion === "string" && s.suggestion.trim() + ? s.suggestion.trim() + : undefined; drafted.push({ id, name, depends_on, + ...(role ? { role } : {}), + ...(from ? { from } : {}), + ...(suggestion ? { suggestion } : {}), ...(params ? { params } : {}), ...(mode ? { mode } : {}), ...(revises ? { revises } : {}), @@ -586,8 +864,9 @@ export function parseCreationFlow(raw: string | undefined | null): CreationFlow const statusRaw = typeof row.status === "string" ? row.status.trim() : ""; const status: CreationFlowStatus | undefined = statusRaw === "open" || statusRaw === "closed" ? statusRaw : undefined; + const suggestions = parsePrototypeSuggestions(row.suggestions); - return { version: 1, brief, status, steps }; + return { version: 1, brief, status, ...(suggestions ? { suggestions } : {}), steps }; } export function parseModuleCatalog(raw: string): ModuleCatalog | null { @@ -855,6 +1134,7 @@ export function stringifyCreationFlow(flow: CreationFlow): string { version: 1, ...(flow.brief ? { brief: flow.brief } : {}), ...(flow.status ? { status: flow.status } : {}), + ...(flow.suggestions?.length ? { suggestions: flow.suggestions } : {}), steps: flow.steps, }); } @@ -868,12 +1148,20 @@ export function mergeCreationFlowPreservingAccepted(params: { nextRaw: string; acceptedStepIds: readonly string[]; }): { raw: string; restored: string[] } { - const next = parseCreationFlow(params.nextRaw); + const nextParsed = parseCreationFlow(params.nextRaw); const prev = parseCreationFlow(params.prevRaw); - if (!next || !prev) return { raw: params.nextRaw, restored: [] }; + if (!nextParsed) return { raw: params.nextRaw, restored: [] }; + const next = stripReviseSteps(nextParsed); + if (!prev) { + return { raw: stringifyCreationFlow(next), restored: [] }; + } - const accepted = prev.steps.filter((s) => isStepAccepted(s, params.acceptedStepIds)); - if (accepted.length === 0) return { raw: params.nextRaw, restored: [] }; + const accepted = prev.steps.filter( + (s) => isStepAccepted(s, params.acceptedStepIds) && !isReviseStep(s), + ); + if (accepted.length === 0) { + return { raw: stringifyCreationFlow(next), restored: [] }; + } const restored: string[] = []; const steps = [...next.steps]; @@ -891,21 +1179,26 @@ export function mergeCreationFlowPreservingAccepted(params: { JSON.stringify(current.depends_on) !== JSON.stringify(frozen.depends_on) || JSON.stringify(current.params ?? null) !== JSON.stringify(frozen.params ?? null) || (current.mode ?? "fresh") !== (frozen.mode ?? "fresh") || - (current.revises ?? "") !== (frozen.revises ?? ""); + (current.revises ?? "") !== (frozen.revises ?? "") || + (current.role ?? "") !== (frozen.role ?? "") || + (current.from ?? "") !== (frozen.from ?? "") || + (current.suggestion ?? "") !== (frozen.suggestion ?? ""); if (changed) { steps[at] = frozen; restored.push(frozen.id); } } - if (restored.length === 0) return { raw: params.nextRaw, restored: [] }; return { - raw: stringifyCreationFlow({ ...next, version: 1, steps }), + raw: stringifyCreationFlow( + stripReviseSteps({ ...next, version: 1, steps }), + ), restored, }; } /** * 配方近期起点 → 可写入黑板的开局 DAG。 + * 开局节点跟剧本(配方 seed.steps)走,不在运行时写死某一步。 * steps 为空则返回 null(仍只当选型参考,不预置流程)。 */ export function creationFlowFromRecipeSeed( @@ -1022,11 +1315,11 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string { }); return [ "【能力 · 可选工序】", - "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:选定后结束创作并保存,排在细化终稿之后,其后不要再追加步骤;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。", - "标〔先验产物〕的步骤(如生成规则、具体实例):须先定「写什么」。编排器可以提前规划多条并写入 params——这是正确的;执行时程序注入【规划产物】。规划可空,「生成什么」在步内钉。禁止为钉对象而 askUser 拦在确认开干前。规划或修订对象时同步改 rule_id(英文 kebab-case)与步骤 id(如 生成规则·{对象}),禁止只改中文沿用旧后台 id。", + "按需选用,勿默认全选;步骤名只能从这里选;标〔可反复〕的可多次编入;标〔收口〕的是终节点:排在细化终稿之后作最后一步;尚未选定开场前,用户要补前序节点(如 NPC)时插在收口之前,勿以「收口已排入」拒绝追加;选定后才结束创作并保存;标〔程序步〕的确认编排后直接执行(不抛默认问题、不经同意并开始),产物仍验收。", + "标〔先验产物〕(生成规则、具体实例):编排层只排 role=prototype 槽位 + suggestion;禁止在原型写 params、禁止预排 instance。「写什么」在用户点原型增殖后的实例步内钉。", "其它有「编排参数」的步骤:确认开干前写齐必填 params;缺参时用 askUser 选项+其它。", "选型依据是下方「何时用 / 何时不用 / 边界」(来自各能力 meta)。", - "两种「再来一次」必须分开:〔可反复〕再编入新 id(mode 缺省/fresh)= 彻底新建一条;回头修改已完成节点 = mode=revise + revises=原步 id,继承旧产物继续改。禁止把回头修改写成又一条 fresh。", + "〔可反复〕再编入新 id = 彻底新建一条。已完成节点要改:禁止排 mode=revise 新步;用户会在图上点该节点重进。禁止把改旧稿写成又一条 fresh。", "不要把能力执行全文塞进本步;执行由 design-step 注入。", lines.join("\n"), ].join("\n"); @@ -1342,7 +1635,7 @@ export function formatFlowProgressForAgent(params: { const accepted = (params.acceptedStepIds ?? []).filter( (id) => Boolean(id?.trim()) && id.trim() !== "flow", ); - const flow = params.flow ?? null; + const flow = params.flow ? stripReviseSteps(params.flow) : null; const catalog = params.catalog ?? null; const filled = new Set(params.filledArtifactTags ?? []); const repeatableNames = new Set( @@ -1362,8 +1655,8 @@ export function formatFlowProgressForAgent(params: { repeatable: boolean, ) => { const flag = repeatable - ? "〔可反复:mode=fresh 再追加 = 彻底新建;要改旧条用 mode=revise〕" - : "〔不可反复:禁止再排新建;要改已完成节点用 mode=revise 回头修改〕"; + ? "〔可反复:mode=fresh 再追加 = 彻底新建〕" + : "〔不可反复:禁止再排新建,也禁止排 mode=revise〕"; const idBit = id ? `(id: ${id})` : ""; doneLines.push(`- ${name}${idBit} · ${why} ${flag}`); doneNames.add(name); @@ -1372,6 +1665,7 @@ export function formatFlowProgressForAgent(params: { if (flow) { for (const step of flow.steps) { + if (isPrototypeStep(step)) continue; const acceptedHere = isStepAccepted(step, accepted); const art = catalog ? artifactTagForStep(step.name, catalog) : null; const hasArtifact = Boolean(art && filled.has(art)); @@ -1405,12 +1699,10 @@ export function formatFlowProgressForAgent(params: { const draftLines: string[] = []; if (flow) { for (const step of flow.steps) { + if (isPrototypeStep(step)) continue; if (doneIds.has(step.id) || isStepAccepted(step, accepted)) continue; - const reviseBit = isReviseStep(step) - ? ` · 回头修改${step.revises ? `(revises: ${step.revises})` : ""}` - : ""; draftLines.push( - `- ${step.name}(id: ${step.id})${reviseBit} · 已在草案,保留原 id;不要当作新规划再写一遍`, + `- ${step.name}(id: ${step.id}) · 已在草案,保留原 id;不要当作新规划再写一遍`, ); } } @@ -1419,13 +1711,45 @@ export function formatFlowProgressForAgent(params: { .filter((m) => m.repeatable === true) .map((m) => m.name); + const prototypeLines: string[] = []; + if (flow) { + for (const step of flow.steps) { + if (!isPrototypeStep(step)) continue; + const sug = step.suggestion?.trim() + ? ` · 建议:${step.suggestion.trim()}` + : ""; + prototypeLines.push( + `- ${step.name}(id: ${step.id},role=prototype)${sug} · 保留;用户在图上点增殖,禁止预排实例或写 params`, + ); + } + } + const suggestionLines = (flow?.suggestions ?? []).map( + (s) => + `- ${s.name} · ${s.suggestion}${s.depends_on?.length ? ` · 依赖 ${s.depends_on.join("、")}` : ""}`, + ); + const lines = [ "【流程进度】(程序钉死,必须遵守)", - "你的任务是追加缺口,不是从头规划。", - "已完成的非反复技能:禁止再排一次「新建」(mode=fresh)。用户要改已完成节点 → 追加新 id,mode=revise,revises=原步 id;程序会继承既有产物。", - "〔可反复〕再编入新 id 且 mode 缺省/fresh = 彻底新建一条(新对象/新批次),不是改旧的。要改已有那一条 → 同样用 mode=revise。", - "草案里已有的步骤:原样保留,不要重排一遍开局。回头修改步插在最近已验收步之后、尚未执行的步之前。", + "你只排 DAG 骨架:节点 + depends_on +(repeatable 能力)原型槽位 + suggestions。不决定用户先跑哪个。", + "〔可反复〕须写 role=prototype,可带 suggestion(建议生成什么);禁止预排 instance、禁止在原型上写 params。「写什么」在实例步 design-step 里钉。", + "已完成的非反复技能:禁止再排新建,禁止排 mode=revise。改已完成节点由用户在图上点选重进,不要在 DAG 里加修订步。", + "要改已有实例:用户点该实例重进。点原型增殖 = 全新 instance(role=instance,from=原型 id)。", + "草案里已有的步骤:原样保留,不要重排一遍开局。", ]; + const closerPending = + catalog && flow + ? flow.steps.filter((step) => { + const mod = catalog.modules.find((m) => m.name === step.name); + return Boolean(mod?.closer) && !isStepAccepted(step, accepted); + }) + : []; + if (closerPending.length) { + lines.push( + `草案已有〔收口〕${closerPending + .map((s) => `「${s.name}」`) + .join("、")}但尚未选定。用户要求补节点(如 NPC、生成规则、具体实例)时:把新步插在收口之前,保持收口为最后一步,并将 status 改回 open。禁止以「收口已排入」为由拒绝追加。`, + ); + } if (doneLines.length) { lines.push("", "已完成:", ...doneLines); } else { @@ -1437,11 +1761,21 @@ export function formatFlowProgressForAgent(params: { if (draftLines.length) { lines.push("", "草案已有、尚未验收(保留,勿重排):", ...draftLines); } + if (prototypeLines.length) { + lines.push("", "原型槽位(保留,勿改成 instance):", ...prototypeLines); + } + if (suggestionLines.length) { + lines.push( + "", + "建议追加的原型(可编入 steps 为 role=prototype):", + ...suggestionLines, + ); + } lines.push( "", repeatableList.length - ? `可反复追加:${repeatableList.join("、")}` - : "可反复追加:以能力目录〔可反复〕为准。", + ? `目录〔可反复〕:${repeatableList.join("、")}(排骨架时用 role=prototype)` + : "目录〔可反复〕:以能力目录为准。", ); return lines.join("\n"); } @@ -1511,7 +1845,13 @@ export function validateCreationFlow( } } } - } else if ( + } else if (isPrototypeStep(step)) { + if (step.params && Object.keys(step.params).length) { + errors.push(`原型「${step.id}」不应带 params(「写什么」在实例步内钉)`); + } + continue; + } + if ( catalog && mod && mod.repeatable !== true && @@ -1521,7 +1861,7 @@ export function validateCreationFlow( ) { reportedFreshDup.add(step.name); errors.push( - `「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能要改旧稿请用 mode=revise)`, + `「${step.name}」出现多次新建,但目录未标 repeatable(非反复技能请点已完成节点重进,不要再排新建)`, ); } @@ -1593,47 +1933,234 @@ export function artifactTagForStep( return catalog?.modules.find((m) => m.name === name)?.artifact ?? null; } +export function finalizeCreationFlow( + flow: CreationFlow, + catalog: ModuleCatalog | null | undefined, +): CreationFlow { + const stripped = stripReviseSteps(flow); + return { + ...stripped, + steps: normalizeFlowStepRoles(stripped.steps, catalog), + }; +} + +const FLOW_TITLE_MAX = 28; + +function flowParamText( + params: CreationFlowStepParams | undefined, + key: string, +): string { + const v = params?.[key]; + if (typeof v === "string") return v.trim(); + if (typeof v === "number" || typeof v === "boolean") return String(v); + return ""; +} + +function firstFlowTitle(...vals: Array): string | undefined { + for (const v of vals) { + const t = v?.trim(); + if (t) return t; + } + return undefined; +} + +function clampFlowTitle(raw: string, max = FLOW_TITLE_MAX): string { + const t = raw.trim(); + if (t.length <= max) return t; + return `${t.slice(0, max - 1)}…`; +} + +function asTitleRecord(v: unknown): Record | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + return v as Record; +} + +/** 建议文案的短标题:冒号前;无冒号且足够短则整句。 */ +export function titleFromSuggestion(suggestion?: string): string | undefined { + const t = suggestion?.trim(); + if (!t) return undefined; + const colon = t.search(/[::]/); + if (colon > 0 && colon <= FLOW_TITLE_MAX) { + const head = t.slice(0, colon).trim(); + const rest = t.slice(colon + 1).trim(); + if (head && rest) return head; + } + if (t.length <= FLOW_TITLE_MAX) return t; + return undefined; +} + +function titleFromStepParams( + params: CreationFlowStepParams | undefined, +): string | undefined { + const target = + flowParamText(params, "target") || + flowParamText(params, "object") || + flowParamText(params, "对象") || + flowParamText(params, "生成对象"); + if (target) return clampFlowTitle(target); + const batch = flowParamText(params, "batch_goal"); + if (batch) return clampFlowTitle(batch); + const ruleId = flowParamText(params, "rule_id"); + if (ruleId) return clampFlowTitle(ruleId); + return undefined; +} + +function titleFromAcceptedSummary( + summary: string | undefined, + skillName: string, +): string | undefined { + const t = summary?.trim(); + if (!t) return undefined; + const parts = t + .split(/\s*·\s*/) + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length >= 2 && (parts[0] === skillName || parts[0] === "生成规则" || parts[0] === "具体实例")) { + return clampFlowTitle(parts[1]!); + } + if (t.length <= FLOW_TITLE_MAX) return t; + return undefined; +} + +function titleFromAcceptedContent(content: unknown): string | undefined { + const row = asTitleRecord(content); + if (!row) return undefined; + const body = asTitleRecord(row.正文) ?? row; + const pinned = asTitleRecord(body.本步参数); + if (typeof pinned?.target === "string" && pinned.target.trim()) { + return clampFlowTitle(pinned.target); + } + const nec = asTitleRecord(body.必要性判断); + if (typeof nec?.生成对象 === "string" && nec.生成对象.trim()) { + return clampFlowTitle(nec.生成对象); + } + const rules = Array.isArray(body.rules) ? body.rules : []; + for (const item of rules) { + const rec = asTitleRecord(item); + if (typeof rec?.对象 === "string" && rec.对象.trim()) { + return clampFlowTitle(rec.对象); + } + } + if (typeof row.brief === "string" && row.brief.trim() && row.brief.trim().length <= FLOW_TITLE_MAX) { + return row.brief.trim(); + } + return undefined; +} + +export type FlowStepTitleHint = { + summary?: string; + content?: unknown; +}; + +/** 图上「增殖的是啥」短标题:验收内容 > 规划参数 > 建议文案。 */ +export function deriveFlowStepTitle(params: { + name: string; + stepParams?: CreationFlowStepParams; + suggestion?: string; + prototypeSuggestion?: string; + hint?: FlowStepTitleHint; +}): string | undefined { + const raw = firstFlowTitle( + titleFromAcceptedContent(params.hint?.content), + titleFromAcceptedSummary(params.hint?.summary, params.name), + titleFromStepParams(params.stepParams), + titleFromSuggestion(params.suggestion), + titleFromSuggestion(params.prototypeSuggestion), + ); + if (!raw || raw === params.name) return undefined; + return raw; +} + export function formatCreationFlowForUser( flow: CreationFlow, catalog?: ModuleCatalog | null, acceptedStepIds: readonly string[] = [], + filledArtifactTags: readonly string[] = [], + titleHints: Readonly> = {}, ): CreationFlowUserView { + const normalized = finalizeCreationFlow(flow, catalog ?? null); const decl = new Map( (catalog?.modules ?? []).map((m) => [m.name, m] as const), ); - const seenFreshName = new Map(); - const next = nextPendingStep(flow, acceptedStepIds); + const seenInstanceName = new Map(); + const layers = computeStepLayers(normalized); + const prototypeNames = new Set( + normalized.steps.filter(isPrototypeStep).map((s) => s.name), + ); + const pendingSuggestions = (normalized.suggestions ?? []).filter( + (s) => !prototypeNames.has(s.name), + ); + const filled = new Set(filledArtifactTags); + const byId = new Map(normalized.steps.map((s) => [s.id, s] as const)); return { - brief: flow.brief, - status: flow.status, - steps: flow.steps.map((s, i) => { + brief: normalized.brief, + status: normalized.status, + ...(pendingSuggestions.length ? { suggestions: pendingSuggestions } : {}), + steps: normalized.steps.map((s, i) => { const revise = isReviseStep(s); + const proto = isPrototypeStep(s); let occurrence: number | undefined; - if (!revise) { - const n = (seenFreshName.get(s.name) ?? 0) + 1; - seenFreshName.set(s.name, n); + if (!revise && isInstanceStep(s)) { + const n = (seenInstanceName.get(s.name) ?? 0) + 1; + seenInstanceName.set(s.name, n); occurrence = n; } const mod = decl.get(s.name); - const paramsMissing = missingRequiredStepParams(s, mod); - const runState: FlowStepRunState = isStepAccepted(s, acceptedStepIds) - ? "done" - : next && next.id === s.id - ? "current" + const paramsMissing = proto + ? [] + : missingRequiredStepParams(s, mod); + const accepted = isStepAccepted(s, acceptedStepIds); + const ready = proto + ? isPrototypeSelectable(normalized, s, acceptedStepIds) + : isStepReady(normalized, s, acceptedStepIds); + const blockedBy = + accepted || ready + ? undefined + : s.depends_on.filter( + (dep) => + !isCreationDependencySatisfied( + normalized, + dep, + acceptedStepIds, + ), + ); + const runState: FlowStepRunState = proto + ? "pending" + : accepted + ? "done" : "pending"; + const parent = s.from ? byId.get(s.from) : undefined; + const title = deriveFlowStepTitle({ + name: s.name, + stepParams: s.params, + suggestion: s.suggestion, + prototypeSuggestion: parent?.suggestion, + hint: titleHints[s.id], + }); return { order: i + 1, id: s.id, name: s.name, depends_on: s.depends_on, + ...(s.role ? { role: s.role } : {}), + ...(s.from ? { from: s.from } : {}), + ...(s.suggestion ? { suggestion: s.suggestion } : {}), + ...(title ? { title } : {}), occurrence, ...(revise ? { mode: "revise" as const } : {}), ...(s.revises ? { revises: s.revises } : {}), declaration: mod?.declaration, repeatable: mod?.repeatable, + ...(isCloserModule(mod, s.name) ? { closer: true } : {}), ...(mod?.kind ? { kind: mod.kind } : {}), + layer: layers.get(s.id) ?? 0, + ready, + selectable: ready, + ...(blockedBy?.length ? { blockedBy } : {}), runState, - ...(s.params ? { params: s.params } : {}), + ...(mod?.artifact ? { artifactTag: mod.artifact } : {}), + ...(mod?.artifact && filled.has(mod.artifact) ? { hasArtifact: true } : {}), + ...(!proto && s.params ? { params: s.params } : {}), ...(paramsMissing.length > 0 ? { paramsMissing } : {}), }; }), @@ -1682,6 +2209,46 @@ export function isStepAccepted( return false; } +/** 未验收且 depends_on 均已满足(原型步不可执行,恒 false) */ +export function isStepReady( + flow: CreationFlow, + step: CreationFlowStep, + acceptedStepIds: readonly string[], +): boolean { + if (isPrototypeStep(step)) return false; + if (isStepAccepted(step, acceptedStepIds)) return false; + return step.depends_on.every((dep) => + isCreationDependencySatisfied(flow, dep, acceptedStepIds), + ); +} + +/** + * 当前所有可点进去的步骤(依赖已齐、尚未验收)。 + * 编排器排出的回头修改步不进入可执行队列。 + */ +export function listReadySteps( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): CreationFlowStep[] { + if (!flow?.steps.length) return []; + return flow.steps.filter( + (step) => + isExecutableStep(step) && + !isReviseStep(step) && + isStepReady(flow, step, acceptedStepIds), + ); +} + +export function listSelectablePrototypes( + flow: CreationFlow | null, + acceptedStepIds: readonly string[], +): CreationFlowStep[] { + if (!flow?.steps.length) return []; + return flow.steps.filter((step) => + isPrototypeSelectable(flow, step, acceptedStepIds), + ); +} + /** * 流程中下一个待做步骤:未验收,且 depends_on 均已验收。 */ @@ -1689,18 +2256,390 @@ export function nextPendingStep( flow: CreationFlow | null, acceptedStepIds: readonly string[], ): CreationFlowStep | null { - if (!flow?.steps.length) return null; - const ready: CreationFlowStep[] = []; - for (const step of flow.steps) { - if (isStepAccepted(step, acceptedStepIds)) continue; - const depsOk = step.depends_on.every((dep) => { + const ready = listReadySteps(flow, acceptedStepIds); + return ready[0] ?? null; +} + +/** 依赖层级:根=0,某步 = max(依赖层级)+1。环则记 0。 */ +export function computeStepLayers(flow: CreationFlow): Map { + const memo = new Map(); + const visiting = new Set(); + const layerOf = (stepId: string): number => { + if (memo.has(stepId)) return memo.get(stepId)!; + if (visiting.has(stepId)) return 0; + const step = findStepByRef(flow, stepId); + if (!step || step.depends_on.length === 0) { + memo.set(stepId, 0); + return 0; + } + visiting.add(stepId); + let max = 0; + for (const dep of step.depends_on) { const depStep = findStepByRef(flow, dep); - if (!depStep) return false; - return isStepAccepted(depStep, acceptedStepIds); - }); - if (depsOk) ready.push(step); + if (!depStep) continue; + max = Math.max(max, layerOf(depStep.id) + 1); + } + visiting.delete(stepId); + memo.set(stepId, max); + return max; + }; + for (const step of flow.steps) layerOf(step.id); + return memo; +} + +export function isCloserModule( + module: { closer?: boolean } | null | undefined, + name?: string, +): boolean { + if (module?.closer === true) return true; + return (name ?? "").trim() === "开场白与开场变量"; +} + +export function findCloserStepIndex( + flow: CreationFlow, + catalog?: ModuleCatalog | null, +): number { + return flow.steps.findIndex((s) => + isCloserModule(findModuleByName(catalog, s.name), s.name), + ); +} + +/** 同能力新 id:name / name#n */ +export function allocateCreationStepId( + existingIds: Iterable, + name: string, +): string { + const used = new Set( + [...existingIds].map((id) => id.trim()).filter(Boolean), + ); + const base = name.trim(); + if (!base) return "步骤"; + if (!used.has(base)) return base; + let n = 2; + while (used.has(`${base}#${n}`)) n += 1; + return `${base}#${n}`; +} + +const CONCRETE_INSTANCE_NAME = "具体实例"; +const GENERATION_RULE_NAME = "生成规则"; + +function lastAcceptedNonCloser( + flow: CreationFlow, + catalog: ModuleCatalog | null | undefined, + acceptedStepIds: readonly string[], +): CreationFlowStep | null { + for (let i = flow.steps.length - 1; i >= 0; i--) { + const step = flow.steps[i]!; + if (!isStepAccepted(step, acceptedStepIds)) continue; + if (isCloserModule(findModuleByName(catalog, step.name), step.name)) { + continue; + } + return step; } - return ready.find((s) => isReviseStep(s)) ?? ready[0] ?? null; + return null; +} + +/** 新开一条可反复能力时钉的 depends_on(优先跟原型步) */ +export function spawnDependsOn( + flow: CreationFlow, + catalog: ModuleCatalog | null | undefined, + moduleName: string, + acceptedStepIds: readonly string[], +): string[] { + const prototype = flow.steps.find( + (s) => s.name === moduleName && isPrototypeStep(s), + ); + if (prototype?.depends_on.length) { + return prototype.depends_on.filter((dep) => + Boolean(findStepByRef(flow, dep)), + ); + } + const existing = flow.steps.find( + (s) => s.name === moduleName && !isReviseStep(s) && !isPrototypeStep(s), + ); + if (existing?.depends_on.length) { + return existing.depends_on.filter((dep) => Boolean(findStepByRef(flow, dep))); + } + if (moduleName === CONCRETE_INSTANCE_NAME) { + const rules = flow.steps.filter( + (s) => + s.name === GENERATION_RULE_NAME && + isInstanceStep(s) && + isStepAccepted(s, acceptedStepIds), + ); + if (rules.length) return [rules[rules.length - 1]!.id]; + } + const last = lastAcceptedNonCloser(flow, catalog, acceptedStepIds); + return last ? [last.id] : []; +} + +export function repeatableSpawnBlockedReason( + flow: CreationFlow, + catalog: ModuleCatalog | null | undefined, + moduleName: string, + acceptedStepIds: readonly string[], +): string | undefined { + const mod = findModuleByName(catalog, moduleName); + if (!mod?.repeatable) return "不是可反复能力"; + if (!acceptedStepIds.some((id) => id.trim() && id.trim() !== "flow")) { + return "先完成至少一步再追加"; + } + if (moduleName === CONCRETE_INSTANCE_NAME) { + const hasRule = flow.steps.some( + (s) => + s.name === GENERATION_RULE_NAME && + isInstanceStep(s) && + isStepAccepted(s, acceptedStepIds), + ); + if (!hasRule) return "先完成一条生成规则"; + } + const deps = spawnDependsOn(flow, catalog, moduleName, acceptedStepIds); + const blocked = deps.filter( + (dep) => !isCreationDependencySatisfied(flow, dep, acceptedStepIds), + ); + if (blocked.length) return `还差:${blocked.join("、")}`; + return undefined; +} + +export function listRepeatableSpawns( + flow: CreationFlow, + catalog: ModuleCatalog, + acceptedStepIds: readonly string[], +): CreationFlowSpawnView[] { + return catalog.modules + .filter((m) => m.repeatable === true && !m.closer) + .filter( + (m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)), + ) + .map((m) => { + const blockedReason = repeatableSpawnBlockedReason( + flow, + catalog, + m.name, + acceptedStepIds, + ); + return { + name: m.name, + ready: !blockedReason, + ...(blockedReason ? { blockedReason } : {}), + }; + }); +} + +export function hasSelectableCreationWork( + flow: CreationFlow | null, + catalog: ModuleCatalog | null | undefined, + acceptedStepIds: readonly string[], +): boolean { + if (listReadySteps(flow, acceptedStepIds).length > 0) return true; + if (listSelectablePrototypes(flow, acceptedStepIds).length > 0) return true; + if (!flow || !catalog) return false; + return listRepeatableSpawns(flow, catalog, acceptedStepIds).some((s) => s.ready); +} + +function allocateInstanceStepId( + prototypeId: string, + flow: CreationFlow, +): string { + let n = flow.steps.filter((s) => s.from === prototypeId).length + 1; + while (flow.steps.some((s) => s.id === `${prototypeId}#${n}`)) n += 1; + return `${prototypeId}#${n}`; +} + +/** + * 点原型槽位:增殖一条 instance 并写入 DAG(插在收口之前)。 + */ +export function spawnInstanceFromPrototype(params: { + flow: CreationFlow; + catalog: ModuleCatalog | null | undefined; + prototypeId: string; + acceptedStepIds: readonly string[]; +}): { flow: CreationFlow; step: CreationFlowStep } | { error: string } { + const prototype = findStepByRef(params.flow, params.prototypeId.trim()); + if (!prototype || !isPrototypeStep(prototype)) { + return { error: `找不到原型节点:${params.prototypeId}` }; + } + if (!isPrototypeSelectable(params.flow, prototype, params.acceptedStepIds)) { + const blocked = prototype.depends_on.filter( + (dep) => + !isCreationDependencySatisfied( + params.flow, + dep, + params.acceptedStepIds, + ), + ); + return { + error: blocked.length + ? `还差:${blocked.join("、")}` + : `「${prototype.name}」暂不可增殖`, + }; + } + const mod = findModuleByName(params.catalog, prototype.name); + if (!mod) return { error: `未知能力:${prototype.name}` }; + + const instanceDeps = resolveInstanceDependsOn({ + flow: params.flow, + catalog: params.catalog, + prototype, + acceptedStepIds: params.acceptedStepIds, + }); + const id = allocateInstanceStepId(prototype.id, params.flow); + const step: CreationFlowStep = { + id, + name: prototype.name, + role: "instance", + from: prototype.id, + depends_on: instanceDeps, + mode: "fresh", + }; + + const steps = [...params.flow.steps]; + const protoIndex = steps.findIndex((s) => s.id === prototype.id); + if (protoIndex >= 0) { + steps.splice(protoIndex + 1, 0, step); + } else { + const closerAt = findCloserStepIndex(params.flow, params.catalog); + if (closerAt >= 0) { + steps.splice(closerAt, 0, step); + } else { + steps.push(step); + } + } + const closerAt = findCloserStepIndex( + { ...params.flow, steps }, + params.catalog, + ); + if (closerAt >= 0) { + const closer = steps[closerAt]!; + if (!closer.depends_on.includes(id)) { + steps[closerAt] = { + ...closer, + depends_on: [...closer.depends_on, id], + }; + } + } + return { + flow: { + ...params.flow, + version: 1, + status: params.flow.status === "closed" ? "open" : params.flow.status, + steps, + }, + step, + }; +} + +/** + * 误点增殖后尚未验收:从图上撤掉这条 instance,并解开其它步对它的依赖。 + * 普通一次性节点、已验收实例原样返回。 + */ +export function removeUnstartedInstance( + flow: CreationFlow, + stepId: string, + acceptedStepIds: readonly string[], +): CreationFlow { + const step = findStepByRef(flow, stepId.trim()); + if (!step || !isInstanceStep(step) || isStepAccepted(step, acceptedStepIds)) { + return flow; + } + return { + ...flow, + steps: flow.steps + .filter((s) => s.id !== step.id) + .map((s) => ({ + ...s, + depends_on: s.depends_on.filter((d) => d !== step.id), + })), + }; +} + +/** 实例步 depends_on:具体实例钉最近已验收规则实例;其余跟原型。 */ +function resolveInstanceDependsOn(params: { + flow: CreationFlow; + catalog: ModuleCatalog | null | undefined; + prototype: CreationFlowStep; + acceptedStepIds: readonly string[]; +}): string[] { + if (params.prototype.name === CONCRETE_INSTANCE_NAME) { + const rules = params.flow.steps.filter( + (s) => + s.name === GENERATION_RULE_NAME && + isInstanceStep(s) && + isStepAccepted(s, params.acceptedStepIds), + ); + if (rules.length) return [rules[rules.length - 1]!.id]; + } + return params.prototype.depends_on.filter((dep) => + Boolean(findStepByRef(params.flow, dep)), + ); +} + +/** + * 在 DAG 里新编一条可反复能力(兼容:优先找原型增殖,无原型则 legacy 新建)。 + */ +export function spawnRepeatableCreationStep(params: { + flow: CreationFlow; + catalog: ModuleCatalog | null | undefined; + moduleName: string; + acceptedStepIds: readonly string[]; +}): { flow: CreationFlow; step: CreationFlowStep } | { error: string } { + const name = params.moduleName.trim(); + const prototype = params.flow.steps.find( + (s) => s.name === name && isPrototypeStep(s), + ); + if (prototype) { + return spawnInstanceFromPrototype({ + flow: params.flow, + catalog: params.catalog, + prototypeId: prototype.id, + acceptedStepIds: params.acceptedStepIds, + }); + } + + const mod = findModuleByName(params.catalog, name); + if (!mod) return { error: `未知能力:${name}` }; + if (mod.repeatable !== true) return { error: `「${name}」不能反复新开` }; + if (isCloserModule(mod, name)) return { error: `「${name}」是收口,不能增殖` }; + const blocked = repeatableSpawnBlockedReason( + params.flow, + params.catalog, + name, + params.acceptedStepIds, + ); + if (blocked) return { error: blocked }; + + const protoId = allocateCreationStepId( + params.flow.steps.map((s) => s.id), + name, + ); + const protoStep: CreationFlowStep = { + id: protoId, + name, + role: "prototype", + depends_on: spawnDependsOn( + params.flow, + params.catalog, + name, + params.acceptedStepIds, + ), + }; + const steps = [...params.flow.steps]; + const closerAt = findCloserStepIndex(params.flow, params.catalog); + if (closerAt >= 0) { + steps.splice(closerAt, 0, protoStep); + } else { + steps.push(protoStep); + } + const withProto: CreationFlow = { + ...params.flow, + steps, + }; + return spawnInstanceFromPrototype({ + flow: withProto, + catalog: params.catalog, + prototypeId: protoId, + acceptedStepIds: params.acceptedStepIds, + }); } /** 当前已列出的步骤是否都已验收(不管 status) */ @@ -1784,6 +2723,8 @@ export type DesignStepBinding = { opening: string | null; /** 回头修改时注入的既有产物 tag */ inheritTag: string | null; + /** 实例步对应原型的规划建议(参考,步内钉) */ + prototypeSuggestion?: string | null; }; /** @@ -1795,6 +2736,8 @@ export async function resolveDesignStepBinding(params: { currentStepName?: string | null; acceptedStepNames?: readonly string[]; skillsRoot?: string; + /** 用户点已完成节点重进:注入既有产物,不走开场 */ + inheritExisting?: boolean; }): Promise { const skillsRoot = params.skillsRoot ?? DEFAULT_SKILLS_ROOT; const catalog = await loadModuleCatalog(params.skillPackRoot, skillsRoot); @@ -1810,17 +2753,26 @@ export async function resolveDesignStepBinding(params: { if (!step) { step = nextPendingStep(flow, accepted); } - if (!step) return null; + if (!step || isPrototypeStep(step)) return null; const module = findModuleByName(catalog, step.name); if (!module) return null; + let prototypeSuggestion: string | null = null; + if (step.from) { + const proto = findStepByRef(flow, step.from); + prototypeSuggestion = proto?.suggestion?.trim() || null; + } + const modulePromptRaw = (await loadModulePrompt(params.skillPackRoot, module.id, skillsRoot)) ?? `# ${module.name}\n\n(模块 prompt.md 缺失,请补充 skills/.../modules/${module.id}/prompt.md)`; + const inherit = + Boolean(params.inheritExisting) || isReviseStep(step); + const inheritTag = inherit ? module.artifact : null; const opening = - isReviseStep(step) || module.auto + inherit || module.auto ? null : module.opening?.trim() || extractModuleOpening(modulePromptRaw) || null; @@ -1830,6 +2782,7 @@ export async function resolveDesignStepBinding(params: { depTags: dependencyArtifactTags(step, catalog, flow), modulePrompt: formatModulePromptForLlm(modulePromptRaw), opening, - inheritTag: isReviseStep(step) ? module.artifact : null, + inheritTag, + prototypeSuggestion, }; } diff --git a/src/skills/declared-worker.ts b/src/skills/declared-worker.ts index 4c8c061..c550157 100644 --- a/src/skills/declared-worker.ts +++ b/src/skills/declared-worker.ts @@ -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 { diff --git a/src/skills/flat-artifact.ts b/src/skills/flat-artifact.ts new file mode 100644 index 0000000..9f368c9 --- /dev/null +++ b/src/skills/flat-artifact.ts @@ -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 = { + 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; + return Array.isArray(row.sections); +} + +/** 扁形 → 旧 fragment 外壳,便于现有渲染;正文按 section id 堆对象。 + * section.text 若是 JSON 对象/数组字符串,解析回结构,避免专用卡吃到整段 stringify。 + */ +export function flatArtifactToFragmentDoc(flat: FlatArtifact): Record { + const 正文: Record = {}; + 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; + + // 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)) { + 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; + 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 ?? ""), + }; +} diff --git a/src/skills/loader.ts b/src/skills/loader.ts index 050c0d7..912926b 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -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; diff --git a/src/skills/opening-seal.ts b/src/skills/opening-seal.ts index 4d63d3f..b17ac8a 100644 --- a/src/skills/opening-seal.ts +++ b/src/skills/opening-seal.ts @@ -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" }); +} diff --git a/src/skills/worker-set-parse.ts b/src/skills/worker-set-parse.ts index af17f02..adc398e 100644 --- a/src/skills/worker-set-parse.ts +++ b/src/skills/worker-set-parse.ts @@ -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("{")) { diff --git a/src/types/context-trace.ts b/src/types/context-trace.ts index e891122..67a183c 100644 --- a/src/types/context-trace.ts +++ b/src/types/context-trace.ts @@ -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( messages: T[], @@ -88,12 +95,15 @@ export function pruneContextTraces 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 }; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index b6638ae..32b11cf 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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; + } + | { + /** 离开当前技能步,回到分层图点选(误点增殖节点时用) */ + type: "user_left_creation_step"; + payload?: Record; + } | { 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" } /** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */ diff --git a/src/worker/executor.ts b/src/worker/executor.ts index 5519670..3b7b92c 100644 --- a/src/worker/executor.ts +++ b/src/worker/executor.ts @@ -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": { "": "<内容字符串>" }, - "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, outputs: Record, @@ -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, + 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; + + // 创作流程 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; const outputsRaw = obj.outputs; const outputs: Record = {}; - if (outputsRaw && typeof outputsRaw === "object") { + if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) { + const bag = outputsRaw as Record; for (const tag of productOutputTags(outputTags)) { - assignOutput(outputs, tag, (outputsRaw as Record)[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[0], - options: Parameters[1], - stream?: WorkerStreamCallbacks, -): Promise>> { - 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 { const workerId = resolveWorkerId(params.workerId); let worker: ParsedWorkerSkill; @@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise 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 供单测 */ diff --git a/tests/capabilities-flat.test.ts b/tests/capabilities-flat.test.ts new file mode 100644 index 0000000..0f0625a --- /dev/null +++ b/tests/capabilities-flat.test.ts @@ -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).交互范式).toEqual({ + 前置配置: { 用户角色: "唯一免疫者" }, + }); + expect( + ((frag.正文 as Record).美学纲领 as Record) + .体验内核, + ).toBe("报复爽"); + }); +}); diff --git a/tests/context-trace.test.ts b/tests/context-trace.test.ts index 45c9aff..f7f6e38 100644 --- a/tests/context-trace.test.ts +++ b/tests/context-trace.test.ts @@ -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); diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index dfbf727..d45292b 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -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"]); }); }); diff --git a/tests/declared-worker.test.ts b/tests/declared-worker.test.ts index cea2732..ec2d386 100644 --- a/tests/declared-worker.test.ts +++ b/tests/declared-worker.test.ts @@ -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"); }); diff --git a/tests/display-labels.test.ts b/tests/display-labels.test.ts index f560275..27765bf 100644 --- a/tests/display-labels.test.ts +++ b/tests/display-labels.test.ts @@ -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); + }); }); diff --git a/tests/generation-compat.test.ts b/tests/generation-compat.test.ts new file mode 100644 index 0000000..77f8c25 --- /dev/null +++ b/tests/generation-compat.test.ts @@ -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 = { 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 = { 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 = { max_completion_tokens: 100 }; + const tried = new Set(); + 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> = []; + 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(); + }); +}); diff --git a/tests/json-doc.test.ts b/tests/json-doc.test.ts new file mode 100644 index 0000000..006b059 --- /dev/null +++ b/tests/json-doc.test.ts @@ -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: "点题", + }); + }); +}); diff --git a/tests/opening-seal.test.ts b/tests/opening-seal.test.ts index 2ee7ad5..ca64023 100644 --- a/tests/opening-seal.test.ts +++ b/tests/opening-seal.test.ts @@ -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"); + }); }); diff --git a/tests/parse-json-doc.test.ts b/tests/parse-json-doc.test.ts new file mode 100644 index 0000000..dc29cb3 --- /dev/null +++ b/tests/parse-json-doc.test.ts @@ -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: "点题" }); + }); +}); diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index 76d4599..c3aef0a 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -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" }, + ]); + }); }); diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index 6a8ca60..5c12082 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -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(["美学纲领与交互范式"]); + }); }); diff --git a/tests/session-persist.test.ts b/tests/session-persist.test.ts index 5cc9a94..1c6abde 100644 --- a/tests/session-persist.test.ts +++ b/tests/session-persist.test.ts @@ -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); + }); }); diff --git a/tests/tool-loop.test.ts b/tests/tool-loop.test.ts index a6565df..cfd1174 100644 --- a/tests/tool-loop.test.ts +++ b/tests/tool-loop.test.ts @@ -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"] }), diff --git a/tests/worker-set-sanitize.test.ts b/tests/worker-set-sanitize.test.ts index 21fcaca..a22f4cf 100644 --- a/tests/worker-set-sanitize.test.ts +++ b/tests/worker-set-sanitize.test.ts @@ -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":['; diff --git a/web/agent-ui.js b/web/agent-ui.js index 0281d12..b8f7dac 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -1,4 +1,5 @@ import { renderIntakePanel } from "./intake-ui.js"; +import { tryParseJsonDoc } from "./json-doc.js"; import { getActiveQuestions, getModuleOpeningPrompt, @@ -6,7 +7,7 @@ import { isQuestionCardDismissed, renderQuestionsCard, } from "./questions-ui.js"; -import { displayWorkerLabel, formatWorkerDisplayTitle, reviewComposerCopy } from "./display-labels.js"; +import { displayWorkerLabel, formatWorkerDisplayTitle, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; import { PRESENT_SHELL_IDS, parsePresentDoc, @@ -158,6 +159,89 @@ function renderMsgVariantBadge(msg) { return renderVariantNavHtml(msg); } +function hideFlowNodeMenu() { + const menu = document.getElementById("flow-node-menu"); + if (menu) menu.hidden = true; + flowMenuState.stepId = null; + flowMenuState.node = null; +} + +const flowMenuState = { + stepId: null, + name: "", + hasArtifact: false, + canReenter: false, + node: null, + handlers: null, +}; + +function placeMenu(menu, x, y) { + menu.hidden = false; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${Math.max(4, window.innerWidth - rect.width - 4)}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${Math.max(4, window.innerHeight - rect.height - 4)}px`; + } +} + +function showFlowNodeMenu(node, x, y, handlers) { + const menu = document.getElementById("flow-node-menu"); + if (!menu || !node) return; + hideMsgMenu(); + const stepId = node.getAttribute("data-flow-done"); + if (!stepId) return; + flowMenuState.stepId = stepId; + flowMenuState.name = node.getAttribute("data-flow-done-name") || ""; + flowMenuState.hasArtifact = node.getAttribute("data-has-artifact") === "1"; + flowMenuState.canReenter = node.getAttribute("data-can-reenter") === "1"; + flowMenuState.node = node; + flowMenuState.handlers = handlers; + const viewBtn = document.getElementById("flow-menu-view"); + const reenterBtn = document.getElementById("flow-menu-reenter"); + if (viewBtn) { + viewBtn.disabled = !flowMenuState.hasArtifact; + viewBtn.title = flowMenuState.hasArtifact ? "" : "这一步还没有产物"; + } + if (reenterBtn) { + reenterBtn.toggleAttribute("hidden", !flowMenuState.canReenter); + } + placeMenu(menu, x, y); +} + +function wireFlowNodeMenu() { + const menu = document.getElementById("flow-node-menu"); + if (!menu || menu.dataset.wired) return; + menu.dataset.wired = "1"; + menu.addEventListener("click", (e) => { + const action = e.target.closest("[data-flow-menu-action]")?.getAttribute("data-flow-menu-action"); + const stepId = flowMenuState.stepId; + const name = flowMenuState.name; + const handlers = flowMenuState.handlers; + hideFlowNodeMenu(); + if (!stepId || !action) return; + if (action === "view") handlers?.onViewStepArtifact?.(stepId, name); + if (action === "reenter") handlers?.onReenterCreationStep?.(stepId); + }); +} + +export function fillStepArtifactDialog(params) { + const dlg = document.getElementById("dialog-step-artifact"); + const title = document.getElementById("step-artifact-title"); + const body = document.getElementById("step-artifact-body"); + if (!dlg || !title || !body) return; + title.textContent = params.name ? `产物 · ${params.name}` : "产物"; + const content = String(params.content || "").trim(); + body.innerHTML = content + ? formatArtifactBodyHtml(content, { defaultOpen: true, flat: true, hideAskSidecar: true }) + : `

这一步还没有产物。

`; + if (typeof dlg.showModal === "function") dlg.showModal(); + else dlg.setAttribute("open", ""); +} + function hideMsgMenu() { const menu = document.getElementById("msg-action-menu"); if (menu) menu.hidden = true; @@ -182,6 +266,7 @@ const msgMenuState = { function showMsgMenu(card, x, y) { const menu = document.getElementById("msg-action-menu"); if (!menu || !card) return; + hideFlowNodeMenu(); const pack = card.closest(".coord-pack"); const rollbackHost = pack || card; // 上下文按单次请求(这条消息)区分,不能回落到整包里的另一条 @@ -623,6 +708,12 @@ function wireRailChrome() { if (toggle && !toggle.dataset.wired) { toggle.dataset.wired = "1"; toggle.addEventListener("click", () => { + const inBook = document.body.dataset.navLevel === "book"; + const collapsed = document.body.classList.contains("rail-collapsed"); + if (inBook && !collapsed) { + window.dispatchEvent(new CustomEvent("wa:nav-root")); + return; + } if (document.body.dataset.railCanCollapse !== "1") { railUserExpanded = true; } else { @@ -643,7 +734,11 @@ function hasStartedCreation(view) { } function syncRailCollapse(view) { - if (view) { + const inBook = document.body.dataset.navLevel === "book"; + if (!inBook) { + document.body.dataset.railCanCollapse = "0"; + railUserExpanded = true; + } else if (view) { document.body.dataset.railCanCollapse = hasStartedCreation(view) ? "1" : "0"; } const canCollapse = document.body.dataset.railCanCollapse === "1"; @@ -651,8 +746,15 @@ function syncRailCollapse(view) { document.body.classList.toggle("rail-collapsed", collapsed); const toggle = document.getElementById("btn-rail-toggle"); if (toggle) { - toggle.title = collapsed ? "展开侧栏" : "收起侧栏"; - toggle.textContent = collapsed ? "›" : "‹"; + if (inBook && !collapsed) { + toggle.title = "返回作品目录"; + toggle.setAttribute("aria-label", "返回作品目录"); + toggle.textContent = "‹"; + } else { + toggle.title = collapsed ? "展开侧栏" : "收起侧栏"; + toggle.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏"); + toggle.textContent = collapsed ? "›" : "‹"; + } } } @@ -668,6 +770,7 @@ export function resetRailChrome() { rail.dataset.collapsed = "1"; } setRailTab("books"); + wireRailChrome(); } function hasUserMessages(view) { @@ -721,6 +824,12 @@ function shouldShowInFeed(msg, view) { } function maybeSyncRail(view) { + if (document.body.dataset.navLevel !== "book") { + railUserExpanded = true; + document.body.dataset.railCanCollapse = "0"; + syncRailCollapse(view); + return; + } const started = hasStartedCreation(view); const wasCollapsible = document.body.dataset.railCanCollapse === "1"; // 刚进入创作:默认缩进;未开写:展开并停在作品 @@ -1212,14 +1321,6 @@ const ARTIFACT_TITLE_KEYS = [ const ARTIFACT_SKIP_KEYS = new Set(["schema", "schema_version"]); -/** 剥 markdown 代码围栏,便于从验收正文里取出 JSON */ -function stripCodeFences(text) { - let t = String(text || "").trim(); - t = t.replace(/^```(?:json|yaml|yml)?\s*\n?/i, ""); - t = t.replace(/\n?```\s*$/i, ""); - return t.trim(); -} - /** 验收正文常为 `## tag\\n\\n{json}` 多段拼接 */ function splitTaggedArtifactSections(text) { const trimmed = String(text || "").trim(); @@ -1237,74 +1338,6 @@ function splitTaggedArtifactSections(text) { }); } -/** 轻度修复模型常出的尾逗号,便于友好渲染而不是整墙原文 */ -function softenJsonText(s) { - return String(s || "") - .replace(/^\uFEFF/, "") - .replace(/,\s*([\]}])/g, "$1") - .replace(/\u2026/g, "") - .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, ""); -} - -function tryParseJsonDoc(text) { - const tryParse = (s) => { - try { - return JSON.parse(s); - } catch { - return null; - } - }; - const cleaned = softenJsonText(stripCodeFences(text)); - let parsed = tryParse(cleaned); - if (parsed != null) return parsed; - // 双重编码:整段是 JSON 字符串 - if ( - (cleaned.startsWith('"') && cleaned.endsWith('"')) || - (cleaned.startsWith("'") && cleaned.endsWith("'")) - ) { - const inner = tryParse(cleaned); - if (typeof inner === "string") { - parsed = tryParse(softenJsonText(inner)); - if (parsed != null) return parsed; - } - } - const objStart = cleaned.indexOf("{"); - const objEnd = cleaned.lastIndexOf("}"); - if (objStart >= 0 && objEnd > objStart) { - parsed = tryParse(cleaned.slice(objStart, objEnd + 1)); - if (parsed != null) return parsed; - // 截断 JSON:从第一个 { 起尽量补全括号后再试 - parsed = tryParse(repairTruncatedJsonObject(cleaned.slice(objStart))); - if (parsed != null) return parsed; - } - const arrStart = cleaned.indexOf("["); - const arrEnd = cleaned.lastIndexOf("]"); - if (arrStart >= 0 && arrEnd > arrStart) { - parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1)); - if (parsed != null) return parsed; - } - return null; -} - -/** 截断的 {… 补齐引号/括号,便于验收卡仍能出 mosaic */ -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(/(? closeBrackets) s += "]".repeat(openBrackets - closeBrackets); - if (opens > closes) s += "}".repeat(opens - closes); - return s; -} - const FRAGMENT_HEADER_KEYS = new Set([ "schema", "技能", @@ -1465,10 +1498,14 @@ function renderArtifactCompactCard(doc, tagTitle, rawSlice, opts = {}) { // 进度指针不是产物:验收区完全隐藏 if (isMetaPointerTag(tagTitle)) return ""; - const normalized = + const prepared = doc && typeof doc === "object" && !Array.isArray(doc) - ? normalizeContextFragmentDoc(doc) + ? prepareArtifactDocForRender(doc) : doc; + const normalized = + prepared && typeof prepared === "object" && !Array.isArray(prepared) + ? normalizeContextFragmentDoc(prepared) + : prepared; const meta = summarizeArtifactDoc(normalized, tagTitle); const full = renderKnownArtifactHtml(normalized, { @@ -1602,28 +1639,34 @@ function renderKnownArtifactHtml(doc, opts = {}) { if (Array.isArray(doc)) { return `
${renderStructuredValueHtml(doc, 0)}
`; } - const schema = doc.schema; - const presentView = parsePresentDoc(doc); + const prepared = prepareArtifactDocForRender(doc); + const schema = prepared.schema; + const presentView = parsePresentDoc(prepared); if (presentView) { return renderPresentShellHtml(presentView.packet, esc, { tweaks: opts.shellTweaks, }); } - if (schema === "settlement.v1" || isSettlementLike(doc)) { - return renderSettlementSectionsHtml(doc); + if (schema === "settlement.v1" || isSettlementLike(prepared)) { + return renderSettlementSectionsHtml(prepared); } - if (schema === "context-fragment.v1" || isContextFragmentLike(doc)) { - return renderContextFragmentHtml(doc, opts); + if ( + schema === "context-fragment.v1" || + schema === "artifact.flat.v1" || + isContextFragmentLike(prepared) || + isFlatArtifactDoc(doc) + ) { + return renderContextFragmentHtml(prepared, opts); } - if (schema === "context-order.v1" || Array.isArray(doc.slots) || Array.isArray(doc.agents)) { - const orderHtml = renderContextOrderHtml(doc); + if (schema === "context-order.v1" || Array.isArray(prepared.slots) || Array.isArray(prepared.agents)) { + const orderHtml = renderContextOrderHtml(prepared); if (orderHtml) return orderHtml; } - if (Array.isArray(doc.真值) || Array.isArray(doc.side_effects) || doc.维护语句约定) { - return renderVariableDesignHtml(doc); + if (Array.isArray(prepared.真值) || Array.isArray(prepared.side_effects) || prepared.维护语句约定) { + return renderVariableDesignHtml(prepared); } - if (doc.play_slots && typeof doc.play_slots === "object") { - return renderPlaySlotsHtml(doc); + if (prepared.play_slots && typeof prepared.play_slots === "object") { + return renderPlaySlotsHtml(prepared); } return null; } @@ -1669,6 +1712,7 @@ function renderPlaySlotsHtml(doc) { function isContextFragmentLike(doc) { if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false; if (doc.schema === "context-fragment.v1") return true; + if (isFlatArtifactDoc(doc)) return true; return ( typeof doc.brief === "string" && (doc.正文 != null || @@ -1678,8 +1722,124 @@ function isContextFragmentLike(doc) { ); } +/** artifact.flat.v1 或带 sections[] 的扁形 wire */ +function isFlatArtifactDoc(doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return false; + if (doc.schema === "artifact.flat.v1") return true; + return Array.isArray(doc.sections) && (doc.skill != null || doc.技能 != null || doc.brief != null); +} + +/** section 文本:JSON 对象/数组还原,避免专用卡吃到 stringify 整段 */ +function coerceSectionTextValue(text) { + const raw = String(text ?? "").trim(); + if (!raw) return ""; + if ( + (raw.startsWith("{") && raw.endsWith("}")) || + (raw.startsWith("[") && raw.endsWith("]")) + ) { + const parsed = tryParseJsonDoc(raw); + if (parsed != null && typeof parsed === "object") return parsed; + } + return String(text ?? ""); +} + +/** 扁形 → context-fragment 外壳,走现有专用卡 */ +function flatArtifactDocToFragment(doc) { + if (!isFlatArtifactDoc(doc)) return null; + const 正文 = {}; + for (const s of doc.sections || []) { + if (!s || typeof s !== "object") continue; + const key = String(s.id || s.title || "").trim(); + if (!key) continue; + 正文[key] = coerceSectionTextValue(s.text); + } + const questions = Array.isArray(doc.questions) ? doc.questions : []; + const openQs = Array.isArray(doc.open_questions) + ? doc.open_questions + : Array.isArray(doc.开放问题) + ? doc.开放问题 + : []; + const stability = doc.stability || doc.稳变 || ""; + return { + schema: "context-fragment.v1", + 技能: doc.skill || doc.技能 || "", + brief: doc.brief || "", + mount: Array.isArray(doc.mount) ? doc.mount : [], + ...(stability ? { 稳变: stability } : {}), + 正文, + 开放问题: openQs, + 追问: { + 导语: "", + 题目: questions.map((q) => ({ + 问: q?.prompt ?? q?.问 ?? "", + 建议选项: Array.isArray(q?.options) + ? q.options + : Array.isArray(q?.建议选项) + ? q.建议选项 + : [], + })), + }, + ...(doc.summary ? { summary: doc.summary } : {}), + }; +} + +/** 展示前:扁形转 fragment;正文里 JSON 字符串还原 */ +function prepareArtifactDocForRender(doc) { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc; + let next = isFlatArtifactDoc(doc) ? flatArtifactDocToFragment(doc) : { ...doc }; + if (!next) next = { ...doc }; + next = normalizeContextFragmentDoc(next); + const body = next.正文 ?? next.body; + if (body && typeof body === "object" && !Array.isArray(body)) { + const fixed = {}; + for (const [k, v] of Object.entries(body)) { + fixed[k] = typeof v === "string" ? coerceSectionTextValue(v) : v; + } + next = { ...next, 正文: fixed }; + } + return next; +} + +/** + * 美学正文对齐:摊平字段收回三柱;JSON 字符串还原;体验内核/呈现要点归入美学纲领。 + */ +function normalizeAestheticsBody(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const out = { ...body }; + + const revive = (v) => (typeof v === "string" ? coerceSectionTextValue(v) : v); + for (const key of ["美学纲领", "交互范式", "设定逻辑"]) { + if (out[key] != null) out[key] = revive(out[key]); + } + + const topKernel = out.体验内核; + const topPresent = out.呈现要点; + if (topKernel != null || topPresent != null) { + let manifesto = out.美学纲领; + if (typeof manifesto === "string" && manifesto.trim()) { + manifesto = { 体验内核: manifesto }; + } else if (!manifesto || typeof manifesto !== "object" || Array.isArray(manifesto)) { + manifesto = {}; + } else { + manifesto = { ...manifesto }; + } + if (topKernel != null && manifesto.体验内核 == null) { + manifesto.体验内核 = revive(topKernel); + } + if (topPresent != null && manifesto.呈现要点 == null) { + manifesto.呈现要点 = revive(topPresent); + } + out.美学纲领 = manifesto; + delete out.体验内核; + delete out.呈现要点; + } + + // 美学纲领若仍是散文字符串,保持;渲染器当体验内核 + return out; +} + function renderContextFragmentHtml(doc, opts = {}) { - const frag = normalizeContextFragmentDoc(doc); + const frag = prepareArtifactDocForRender(doc); const parts = []; // 紧凑卡外层已展示 brief/挂载,展开内不再重复头区 if (!opts.embed) { @@ -1897,44 +2057,28 @@ function renderSpecialtyBodyHtml(body, skill) { body.设定逻辑 != null || body.交互范式 != null || body.美学纲领 != null || + body.体验内核 != null || + body.呈现要点 != null || skillName.includes("美学"); if (!looksAesthetics) return ""; - // 左右约定(验收卡通用意向): - // 左 = 最终会插入的游玩契约(美学纲领 + 交互范式) - // 右 = 评估/诊断(设定逻辑里的完备度、待探、区域化等) - // 注意:当前黑板仍存整份正文;full 投影时右栏也会进模型——布局先对齐「该审什么」。 + // 左契约 / 右诊断;验收阅读层:无工程说明条、无常驻 hint + body = normalizeAestheticsBody(body); const productKeys = [ - { - key: "美学纲领", - hint: "体验内核与呈现要点", - missing: "(未写美学纲领)", - }, - { - key: "交互范式", - hint: "人称、描写权限、后果与等待", - missing: "(未写交互范式)", - }, - ]; - const evalKeys = [ - { - key: "设定逻辑", - hint: "变造定位 + 参与/内容维度诊断(完备度、依据、待探)", - missing: "(未写设定逻辑)", - }, + { key: "美学纲领", missing: "(未写)" }, + { key: "交互范式", missing: "(未写)" }, ]; + const evalKeys = [{ key: "设定逻辑", missing: "(未写)" }]; const used = new Set([...productKeys, ...evalKeys].map((b) => b.key)); const renderBlock = (b, cls) => { - const inner = - body[b.key] != null - ? renderAestheticsNodeHtml(body[b.key], b.key, 0) - : `

${esc(b.missing)}

`; - const ok = body[b.key] != null; + const ok = body[b.key] != null && body[b.key] !== ""; + const inner = ok + ? renderAestheticsNodeHtml(body[b.key], b.key, 0) + : `

${esc(b.missing)}

`; return `

${esc(b.key)}${ok ? "" : `缺`}

-

${esc(b.hint)}

${inner}
`; @@ -1948,22 +2092,17 @@ function renderSpecialtyBodyHtml(body, skill) { ([k, v]) => `

${esc(k)}

-
${renderStructuredValueHtml(v, 0)}
+
${renderAestheticsNodeHtml(v, k, 0)}
`, ) .join(""); - return `
-
- 左游玩契约(插入意向) - 右设定诊断 / 评估 - 存盘仍是整份正文;默认 full 时两侧都会注入 -
+ return `
- ${productHtml || `

(契约块为空)

`} + ${productHtml || `

(契约为空)

`}
-
`; } - return `
${esc(k)}${esc(String(v))}
`; + const pending = k === "待探"; + return `
${esc(k)}${esc(String(v))}
`; }); return `
${head}${rows.length ? `
${rows.join("")}
` : ""}
`; } @@ -3206,17 +3369,104 @@ function highlightJson(pretty) { .join("\n"); } -function renderCreationFlowView(flowView) { +function flowParamStr(params, key) { + const v = params?.[key]; + if (v == null) return ""; + if (typeof v === "string") return v.trim(); + if (typeof v === "number" || typeof v === "boolean") return String(v); + return ""; +} + +function flowSuggestionRemainder(suggestion, title) { + const text = typeof suggestion === "string" ? suggestion.trim() : ""; + const head = typeof title === "string" ? title.trim() : ""; + if (!text) return ""; + if (!head) return text; + if (text === head) return ""; + const prefixes = [`${head}:`, `${head}:`, `${head} `]; + for (const p of prefixes) { + if (text.startsWith(p)) return text.slice(p.length).trim(); + } + return text; +} + +function flowInstanceTitle(s) { + if (s.title) return s.title; + const target = flowParamStr(s.params, "target"); + if (target) return target; + const batch = flowParamStr(s.params, "batch_goal"); + if (batch) return batch.length > 28 ? `${batch.slice(0, 27)}…` : batch; + const ruleId = flowParamStr(s.params, "rule_id"); + if (ruleId) return ruleId; + if (s.id && s.id !== s.name) { + const stripped = s.id.startsWith(s.name) + ? s.id.slice(s.name.length).replace(/^[\s#·]+/, "") + : ""; + if (stripped && !/^\d+$/.test(stripped)) return stripped; + } + return s.name; +} + +/** 具体实例 → 产出它的那条生成规则(文案 + id,供从属标记 / 连线) */ +function flowInstanceOrigin(s, steps) { + if (s.name !== "具体实例") return null; + const list = Array.isArray(steps) ? steps : []; + const byId = new Map(list.map((x) => [x.id, x])); + const ruleId = flowParamStr(s.params, "rule_id"); + let rule = null; + for (const dep of s.depends_on || []) { + const st = byId.get(dep); + if (st?.name === "生成规则") { + rule = st; + break; + } + } + if (!rule && ruleId) { + rule = + list.find( + (x) => + x.name === "生成规则" && + x.role === "instance" && + flowParamStr(x.params, "rule_id") === ruleId, + ) || null; + } + if (!rule && !ruleId) return null; + const label = rule + ? rule.title || + flowParamStr(rule.params, "target") || + flowParamStr(rule.params, "rule_id") || + rule.id + : ruleId; + return { label, fromId: rule?.id || "" }; +} + +function groupFlowFamilies(steps) { + const byId = new Map(steps.map((s) => [s.id, s])); + const childrenOf = new Map(); + const nested = new Set(); + for (const s of steps) { + if (s.role !== "instance" || !s.from || s.mode === "revise") continue; + const proto = byId.get(s.from); + if (!proto || proto.role !== "prototype") continue; + nested.add(s.id); + if (!childrenOf.has(s.from)) childrenOf.set(s.from, []); + childrenOf.get(s.from).push(s); + } + return { childrenOf, nested }; +} + +function renderCreationFlowView(flowView, opts = {}) { if (!flowView) return ""; if (flowView.parseError) { return ``; } if (!flowView.steps?.length) return ""; + const interactive = Boolean(opts.interactive); const brief = flowView.brief ? `

${esc(flowView.brief)}

` : ""; @@ -3229,54 +3479,353 @@ function renderCreationFlowView(flowView) { const statusHtml = statusLabel ? `

${esc(statusLabel)}

` : ""; - const rows = flowView.steps - .map((s) => { - const deps = - s.depends_on?.length > 0 - ? s.depends_on.map((d) => esc(d)).join("、") - : "无"; - const occ = - s.mode === "revise" - ? `回头修改` - : s.occurrence && s.occurrence > 1 - ? `第 ${esc(String(s.occurrence))} 次` - : s.repeatable - ? `可反复` - : ""; - const nameLabel = - s.id && s.id !== s.name - ? `${esc(s.name)} (${esc(s.id)})` - : esc(s.name); - const paramsText = formatFlowParams(s.params); - const paramsMissing = - s.paramsMissing?.length > 0 - ? `缺参:${esc(s.paramsMissing.join("、"))}` - : ""; - const paramsHtml = paramsText - ? `${esc(paramsText)}` - : ""; - const runState = s.runState === "done" || s.runState === "current" ? s.runState : "pending"; - const runLabel = - runState === "done" ? "已执行" : runState === "current" ? "将要执行" : "未执行"; - const currentAttr = runState === "current" ? ' aria-current="step"' : ""; - return `
  • - ${esc(String(s.order))} - ${esc(runLabel)} - ${nameLabel}${occ} - ${paramsHtml} - ${paramsMissing} - 依赖:${deps} -
  • `; + + const allSteps = flowView.steps; + const { childrenOf, nested } = groupFlowFamilies(allSteps); + const layers = new Map(); + for (const s of allSteps) { + if (nested.has(s.id)) continue; + const layer = Number.isFinite(s.layer) ? s.layer : 0; + if (!layers.has(layer)) layers.set(layer, []); + layers.get(layer).push(s); + } + const layerKeys = [...layers.keys()].sort((a, b) => a - b); + const rows = layerKeys + .map((layer) => { + const nodes = layers + .get(layer) + .map((s) => { + if (s.role === "prototype") { + return renderFlowFamily( + s, + childrenOf.get(s.id) || [], + interactive, + allSteps, + ); + } + return renderFlowGraphNode(s, interactive, { allSteps }); + }) + .join(""); + return `
    +
    第 ${esc(String(layer + 1))} 层
    +
    ${nodes}
    +
    `; }) .join(""); - return `
    + const suggestions = Array.isArray(flowView.suggestions) + ? flowView.suggestions + : []; + const suggestionHtml = suggestions.length + ? `
    +
    建议追加的原型
    + ${suggestions + .map( + (sg) => + `
    ${esc(sg.name)}${esc(sg.suggestion)}
    `, + ) + .join("")} +
    ` + : ""; + + return `
    ${brief} ${statusHtml} -
      ${rows}
    +
    + +
    ${rows}
    +
    + ${suggestionHtml}
    `; } +function renderFlowFamily(proto, children, interactive, allSteps) { + const head = renderFlowGraphNode(proto, interactive, { + allSteps, + spawnCount: children.length, + }); + if (!children.length) return head; + const spawns = children + .map( + (c) => + `
  • ${renderFlowGraphNode(c, interactive, { + compact: true, + allSteps, + })}
  • `, + ) + .join(""); + return `
    + ${head} +
      ${spawns}
    +
    `; +} + +function renderFlowGraphNode(s, interactive, opts = {}) { + const compact = Boolean(opts.compact); + const allSteps = opts.allSteps || []; + const spawnCount = Number(opts.spawnCount) || 0; + const isProto = s.role === "prototype"; + const origin = + s.role === "instance" && s.name === "具体实例" + ? flowInstanceOrigin(s, allSteps) + : null; + const occ = compact + ? "" + : s.mode === "revise" + ? `回头修改` + : isProto + ? `原型` + : s.occurrence && s.occurrence > 1 + ? `第 ${esc(String(s.occurrence))} 次` + : s.role === "instance" + ? `实例` + : s.closer + ? `收口` + : ""; + const nameLabel = compact + ? esc(flowInstanceTitle(s)) + : s.id && s.id !== s.name + ? `${esc(s.name)} (${esc(s.id)})` + : esc(s.name); + const titleHtml = + !compact && s.title + ? `${esc(s.title)}` + : ""; + const suggestionText = flowSuggestionRemainder(s.suggestion, s.title); + const usedParamKeys = compact + ? [ + "target", + s.name === "具体实例" ? "rule_id" : "", + s.name === "具体实例" ? "batch_goal" : "", + ].filter(Boolean) + : []; + const paramsText = !isProto + ? compact + ? formatFlowParamsCompact(s.params, usedParamKeys) + : formatFlowParams(s.params) + : ""; + const paramsMissing = + s.paramsMissing?.length > 0 + ? `缺参:${esc(s.paramsMissing.join("、"))}` + : ""; + const paramsHtml = paramsText + ? `${esc(paramsText)}` + : ""; + const suggestionHtml = + isProto && suggestionText + ? `${esc(suggestionText)}` + : ""; + const originHtml = + origin?.label + ? `来自 ${esc(origin.label)}` + : ""; + const spawnCountHtml = + isProto && spawnCount > 0 + ? `已有 ${esc(String(spawnCount))} 条产物` + : ""; + const runState = + s.runState === "done" || s.runState === "current" ? s.runState : "pending"; + const selectable = Boolean(s.selectable) && interactive; + const doneMenu = !isProto && runState === "done"; + const canReenter = doneMenu && interactive; + const runLabel = isProto + ? selectable + ? "可增殖" + : "待前置" + : s.runState === "done" + ? "已完成" + : selectable + ? "可进入" + : s.blockedBy?.length + ? "待前置" + : "未执行"; + const actionLabel = selectable + ? isProto + ? "增殖" + : "进入" + : ""; + const actionHtml = actionLabel + ? `${esc(actionLabel)}` + : ""; + const doneMenuBtn = doneMenu + ? `` + : ""; + const blocked = + !selectable && !doneMenu && s.blockedBy?.length + ? ` title="还差:${esc(s.blockedBy.join("、"))}"` + : isProto && selectable + ? ` title="确认当前编排,点一下新开一条产物再进入"` + : selectable + ? ` title="确认当前编排并进入"` + : doneMenu + ? ` title="查看产物或重新进入修改"` + : ""; + const currentAttr = runState === "current" ? ' aria-current="step"' : ""; + const clickable = selectable || doneMenu; + const tag = clickable ? "button" : "div"; + const typeAttr = clickable ? ' type="button"' : ""; + const pickAttr = selectable ? ` data-flow-pick="${esc(s.id)}"` : ""; + const doneAttr = doneMenu + ? ` data-flow-done="${esc(s.id)}" data-flow-done-name="${escAttr(s.name)}" data-has-artifact="${s.hasArtifact ? "1" : "0"}" data-can-reenter="${canReenter ? "1" : "0"}" aria-haspopup="menu" aria-label="已完成:${escAttr(s.title || s.name)}"` + : ""; + const clickableClass = selectable + ? " is-selectable" + : doneMenu + ? " is-done-menu" + : ""; + const protoClass = isProto ? " flow-node--prototype" : ""; + const spawnClass = compact ? " flow-node--spawn" : ""; + const lockedClass = + !selectable && !doneMenu && s.blockedBy?.length ? " is-locked" : ""; + const depsJson = escAttr(JSON.stringify(s.depends_on ?? [])); + const fromAttr = s.from ? ` data-flow-from="${escAttr(s.from)}"` : ""; + const originAttr = + origin?.fromId ? ` data-flow-origin="${escAttr(origin.fromId)}"` : ""; + const metaHtml = compact + ? `${actionHtml || doneMenuBtn || `${esc(runLabel)}`}` + : `${actionHtml}${doneMenuBtn}`; + return `<${tag} class="flow-node${protoClass}${spawnClass}${clickableClass}${lockedClass}" data-flow-id="${escAttr(s.id)}" data-flow-name="${escAttr(s.name)}" data-flow-deps="${depsJson}" data-run="${escAttr(runState)}"${fromAttr}${originAttr}${currentAttr}${typeAttr}${pickAttr}${doneAttr}${blocked}> + + ${compact ? "" : `${esc(runLabel)}`} + ${nameLabel}${occ} + ${titleHtml} + ${suggestionHtml} + ${spawnCountHtml} + ${originHtml} + ${paramsHtml} + ${paramsMissing} + + ${metaHtml} + `; +} + +function paintFlowDagEdges(dag) { + if (!dag) return; + const svg = dag.querySelector(".flow-dag-edges"); + if (!svg) return; + const nodes = [...dag.querySelectorAll("[data-flow-id]")]; + if (!nodes.length) { + svg.replaceChildren(); + return; + } + const byId = new Map(); + const byName = new Map(); + for (const el of nodes) { + const id = el.getAttribute("data-flow-id"); + const name = el.getAttribute("data-flow-name"); + if (id) byId.set(id, el); + if (name && !byName.has(name)) byName.set(name, el); + } + const findNode = (ref) => byId.get(ref) || byName.get(ref) || null; + const dagRect = dag.getBoundingClientRect(); + const w = Math.max(dag.scrollWidth, dag.clientWidth, 1); + const h = Math.max(dag.scrollHeight, dag.clientHeight, 1); + svg.setAttribute("width", String(w)); + svg.setAttribute("height", String(h)); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + + const paths = []; + for (const el of nodes) { + let deps = []; + try { + deps = JSON.parse(el.getAttribute("data-flow-deps") || "[]"); + } catch { + deps = []; + } + if (!Array.isArray(deps) || !deps.length) continue; + const toRect = el.getBoundingClientRect(); + const x2 = toRect.left - dagRect.left + toRect.width / 2 + dag.scrollLeft; + const y2 = toRect.top - dagRect.top + dag.scrollTop; + for (const ref of deps) { + const from = findNode(String(ref)); + if (!from || from === el) continue; + const fromId = from.getAttribute("data-flow-id") || ""; + if (el.getAttribute("data-flow-from") === fromId) continue; + const fromRect = from.getBoundingClientRect(); + const x1 = + fromRect.left - dagRect.left + fromRect.width / 2 + dag.scrollLeft; + const y1 = fromRect.bottom - dagRect.top + dag.scrollTop; + const dy = Math.max(24, (y2 - y1) * 0.45); + const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} C ${x1.toFixed(1)} ${(y1 + dy).toFixed(1)}, ${x2.toFixed(1)} ${(y2 - dy).toFixed(1)}, ${x2.toFixed(1)} ${y2.toFixed(1)}`; + const belong = el.getAttribute("data-flow-origin") === fromId; + const active = + from.classList.contains("is-selectable") || + el.classList.contains("is-selectable") || + from.getAttribute("data-run") === "done"; + const cls = `flow-dag-edge${active ? " is-lit" : ""}${belong ? " flow-dag-edge--belong" : ""}`; + const marker = belong + ? "url(#flow-dag-arrow-belong)" + : "url(#flow-dag-arrow)"; + paths.push( + ``, + ); + } + } + svg.innerHTML = ` + + + + + + + ${paths.join("")}`; +} + +function wireCreationFlowGraph(root, handlers) { + if (!root) return; + wireFlowNodeMenu(); + root.querySelectorAll("[data-flow-pick]").forEach((el) => { + el.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const stepId = el.getAttribute("data-flow-pick"); + if (stepId) handlers.onPickCreationStep?.(stepId); + }); + }); + root.querySelectorAll("[data-flow-done]").forEach((el) => { + const openMenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + showFlowNodeMenu(el, e.clientX, e.clientY, handlers); + }; + el.addEventListener("click", openMenu); + el.addEventListener("contextmenu", openMenu); + el.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " " && e.key !== "ContextMenu") return; + e.preventDefault(); + const rect = el.getBoundingClientRect(); + showFlowNodeMenu(el, rect.left, rect.bottom + 4, handlers); + }); + }); + root.querySelectorAll("[data-flow-spawn]").forEach((el) => { + el.addEventListener("click", (e) => { + e.preventDefault(); + const name = el.getAttribute("data-flow-spawn"); + if (name) handlers.onSpawnCreationStep?.(name); + }); + }); + + root.querySelectorAll("[data-flow-dag]").forEach((dag) => { + const paint = () => { + if (!document.contains(dag)) return; + paintFlowDagEdges(dag); + }; + paint(); + requestAnimationFrame(paint); + if (dag.dataset.flowEdgesWired === "1") return; + dag.dataset.flowEdgesWired = "1"; + if (typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => paint()); + ro.observe(dag); + dag.querySelectorAll("[data-flow-id]").forEach((n) => ro.observe(n)); + dag.querySelectorAll(".flow-family").forEach((n) => ro.observe(n)); + } + window.addEventListener("resize", paint, { passive: true }); + }); +} + function formatFlowParams(params) { if (!params || typeof params !== "object" || Array.isArray(params)) return ""; const parts = Object.entries(params) @@ -3285,6 +3834,20 @@ function formatFlowParams(params) { return parts.length ? parts.join(" · ") : ""; } +function formatFlowParamsCompact(params, usedKeys = []) { + if (!params || typeof params !== "object" || Array.isArray(params)) return ""; + const skip = new Set(usedKeys); + const parts = []; + for (const [k, v] of Object.entries(params)) { + if (skip.has(k) || v == null) continue; + const text = typeof v === "string" ? v.trim() : JSON.stringify(v); + if (!text) continue; + parts.push(`${k}=${text.length > 24 ? `${text.slice(0, 23)}…` : text}`); + if (parts.length >= 2) break; + } + return parts.join(" · "); +} + function wireContextOrderEditor(root, sessionId) { if (!root || !sessionId) return; const post = (body) => @@ -3658,6 +4221,7 @@ function resolveDesignSurface(view, loading) { const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason)); if (busy) return "busy"; if (view.phase === "error") return "speak"; + if (isFlowPlanReview(view)) return "speak"; if (view.waitingReason?.kind === "review_artifact" && view.reviewArtifact) { return "review"; } @@ -3688,6 +4252,24 @@ function ensureQuestionsHostIn(parent) { return host; } +function hasLeaveStepAction(view) { + return (view?.actions ?? []).some((a) => a.type === "leave_step"); +} + +function leaveStepControlHtml(view) { + if (!hasLeaveStepAction(view)) return ""; + const label = + view.actions.find((a) => a.type === "leave_step")?.label || "返回节点"; + return ``; +} + +function wireLeaveStepControl(root, handlers) { + if (!root || !handlers?.onLeaveCreationStep) return; + root.querySelectorAll("[data-act=leave-step]").forEach((btn) => { + btn.addEventListener("click", () => handlers.onLeaveCreationStep()); + }); +} + function proposedOutputCopy(proposed) { const params = proposed?.params; const target = @@ -3721,13 +4303,19 @@ function renderSpeakWorkspace(view) { view.lifecycleStage !== "play" ? String(guide.text).trim() : ""; - const showOpening = Boolean(openingText); + const showOpening = + Boolean(openingText) && + wr?.kind !== "pick_creation_step" && + !isFlowPlanReview(view); let title = "继续说"; let hint = "在底栏输入你的想法。"; let guideHtml = ""; - if (showOpening) { + if (wr?.kind === "pick_creation_step" || isFlowPlanReview(view)) { + title = "选要做的节点"; + hint = "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发。"; + } else if (showOpening) { title = guide?.stepName || (moduleOpening ? "按引导先说几句" : "开局"); hint = moduleOpening ? "按下面几点先说几句即可,不必整齐;写完发送后继续产出。" @@ -3781,21 +4369,34 @@ function renderSpeakWorkspace(view) { wr?.kind === "intake" && view.intake?.fields?.length && hasUserMessages(view) ? `
    ${renderIntakePanel(view.intake, { variant: "feed" })}
    ` : ""; + const flowInteractive = + wr?.kind === "pick_creation_step" || isFlowPlanReview(view); + const flowHtml = + (flowInteractive || wr?.kind === "approve_step") && view.creationFlowView + ? renderCreationFlowView(view.creationFlowView, { + interactive: flowInteractive, + }) + : ""; return `
    - 说话 +
    + ${flowInteractive ? "流程" : "说话"} + ${leaveStepControlHtml(view)} +

    ${esc(title)}

    ${esc(hint)}

    ${recipe} ${guideHtml} ${intake} + ${flowHtml}
    `; } -function renderAnswerWorkspaceShell() { +function renderAnswerWorkspaceShell(view) { return `
    答题

    点字母选中,文案可改;点卡片展开,点顶条收起。

    + ${leaveStepControlHtml(view)}
    `; } @@ -3861,6 +4462,7 @@ function mountReviewWorkspace(stage, view, handlers, opts = {}) { }) + askSlot; wireMessageFeedActions(stage, handlers); wireMessageContextMenu(stage, handlers); + wireCreationFlowGraph(stage, handlers); wireOpeningPicker(); if (opts.mountAskCard) { const askHost = document.getElementById("workspace-review-ask"); @@ -3886,7 +4488,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { const reviewingNow = !isPlayView(view) && view.waitingReason?.kind === "review_artifact" && - Boolean(view.reviewArtifact); + Boolean(view.reviewArtifact) && + !isFlowPlanReview(view); document.body.classList.toggle("is-reviewing", reviewingNow); document.body.classList.toggle("design-workspace", designWorkspace); @@ -3971,7 +4574,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (surface === "answer") { stage.hidden = false; - stage.innerHTML = renderAnswerWorkspaceShell(); + stage.innerHTML = renderAnswerWorkspaceShell(view); + wireLeaveStepControl(stage, handlers); const slot = document.getElementById("workspace-answer-slot"); if (slot) ensureQuestionsHostIn(slot); return; @@ -3980,6 +4584,8 @@ export function renderMessageFeed(view, loading, handlers = {}) { // speak(含 approve / intake / revision / error) stage.hidden = false; stage.innerHTML = renderSpeakWorkspace(view); + wireCreationFlowGraph(stage, handlers); + wireLeaveStepControl(stage, handlers); if (panelFeed) ensureQuestionsHostIn(panelFeed); return; } @@ -4112,7 +4718,7 @@ export function renderMessageFeed(view, loading, handlers = {}) { if (stage && !stage.hidden) stage.scrollTop = 0; } -/** 轮询时仅更新流式 pending 卡片,避免整页重绘 */ +/** 轮询时仅更新流式 pending 卡片,避免整页重绘;不自动滚到底,保持用户当前阅读位置 */ export function updateLiveStreamPanel(view) { const pending = document.getElementById("msg-live-pending"); if (!pending) return; @@ -4124,13 +4730,22 @@ export function updateLiveStreamPanel(view) { const next = renderLiveStreamBody(live, view); const hadThinking = Boolean(body.querySelector(".msg-live-pre")); if (next.includes("msg-live-empty") && hadThinking) return; + + const scrollPres = [...body.querySelectorAll(".msg-live-pre")].map((el) => ({ + top: el.scrollTop, + })); + const bodyScrollTop = body.scrollTop; + const stage = pending.closest(".workspace-stage, #workspace-stage") || pending.parentElement; + const stageScrollTop = stage?.scrollTop ?? 0; + body.innerHTML = next; - const pre = body.querySelector(".msg-live-pre:last-of-type"); - if (pre) pre.scrollTop = pre.scrollHeight; - const feed = document.getElementById("message-feed"); - if (feed && !document.body.classList.contains("design-workspace")) { - feed.scrollTop = feed.scrollHeight; - } + + const newPres = body.querySelectorAll(".msg-live-pre"); + newPres.forEach((el, i) => { + if (scrollPres[i]) el.scrollTop = scrollPres[i].top; + }); + body.scrollTop = bodyScrollTop; + if (stage) stage.scrollTop = stageScrollTop; } export function renderBoardPanel(view) { @@ -4222,8 +4837,17 @@ export function renderWorkspace(view, loading, _onPickSkill, handlers = {}) { document.addEventListener("click", (e) => { if (!e.target.closest("#msg-action-menu")) hideMsgMenu(); + if (!e.target.closest("#flow-node-menu") && !e.target.closest("[data-flow-done]")) { + hideFlowNodeMenu(); + } }); document.addEventListener("keydown", (e) => { - if (e.key === "Escape") hideMsgMenu(); + if (e.key === "Escape") { + hideMsgMenu(); + hideFlowNodeMenu(); + } }); -document.addEventListener("scroll", hideMsgMenu, true); +document.addEventListener("scroll", () => { + hideMsgMenu(); + hideFlowNodeMenu(); +}, true); diff --git a/web/app.js b/web/app.js index 86162ff..fe1e6f8 100644 --- a/web/app.js +++ b/web/app.js @@ -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 = "" } = {}) {
    `; } +function leaveStepButtonHtml(spec) { + if (!spec.leaveStep) return ""; + return ``; +} + +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 = `
    @@ -556,7 +622,7 @@ function renderComposer(view, loading) { if (spec.mode === "action") { const reject = spec.showReject - ? ` + ? ` ` : ""; const chip = composerModeChip(spec); @@ -611,6 +677,9 @@ function renderComposer(view, loading) { : "具体写什么在步骤内确定。" }

    ` : ""; + const closerHint = p.closer + ? `

    这是收口步。若还没做 NPC 等前序节点,点「改意见」说明要补什么,会回到流程编排。

    ` + : ""; root.innerHTML = `
    @@ -619,11 +688,12 @@ function renderComposer(view, loading) {

    ${esc(proposedOutputCopy(p))}

    ${planHint} + ${closerHint} ${fields ? `
    ${fields}
    ` : ""} ${syncHint}
    - +
    `; @@ -687,6 +757,17 @@ function renderComposer(view, loading) { return; } + if (spec.mode === "pick_step") { + const shell = composerInputShell(spec, { + textareaHtml: ``, + trailing: ``, + }); + root.innerHTML = `
    ${shell}
    `; + 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: ``, - trailing: `

    `; $("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 = `

    点击左侧 + 新建作品

    `; + $("message-feed").innerHTML = emptyCopy ? `

    ${emptyCopy}

    ` : ""; 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 = `
    暂无打开的作品
    `; +} + +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(); diff --git a/web/display-labels.js b/web/display-labels.js index 54effb9..285fc71 100644 --- a/web/display-labels.js +++ b/web/display-labels.js @@ -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: "修改意见…", diff --git a/web/index.html b/web/index.html index b7edf9e..85feb65 100644 --- a/web/index.html +++ b/web/index.html @@ -12,25 +12,28 @@ /> - +
    + -
    - - -
    @@ -50,9 +53,7 @@
    -
    -

    正在打开作品…

    -
    +
    @@ -138,6 +139,21 @@ + + + +
    +

    产物

    +
    +
    + +
    +
    +
    +

    上下文调用日志

    diff --git a/web/json-doc.js b/web/json-doc.js new file mode 100644 index 0000000..61c98fe --- /dev/null +++ b/web/json-doc.js @@ -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(/(? 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; +} diff --git a/web/settings.css b/web/settings.css index 60d9338..f6240d3 100644 --- a/web/settings.css +++ b/web/settings.css @@ -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; diff --git a/web/settings.html b/web/settings.html index 869dff8..82db927 100644 --- a/web/settings.html +++ b/web/settings.html @@ -148,6 +148,17 @@ Model +
    diff --git a/web/settings.js b/web/settings.js index 09c8fb2..e08b58d 100644 --- a/web/settings.js +++ b/web/settings.js @@ -150,6 +150,31 @@ function activateButtonHtml(active, id, action) { return ``; } +function capabilityBadge(caps) { + if (!caps || !caps.testedAt) { + return `
    能力:未探测(选用前建议点「探测」)
    `; + } + const bit = (label, status) => { + const cls = + status === "ok" ? "cap-ok" : status === "fail" ? "cap-fail" : "cap-unk"; + return `${escapeHtml(label)}:${escapeHtml(status)}`; + }; + const mode = + caps.jsonSchema === "ok" + ? "JSON Schema" + : caps.forcedTool === "ok" + ? "强制 Tool" + : caps.jsonObject === "ok" + ? "JSON Object" + : "纯文本"; + return `
    + 投递:${escapeHtml(mode)} + ${bit("schema", caps.jsonSchema)} + ${bit("tool", caps.forcedTool)} + ${bit("json", caps.jsonObject)} +
    `; +} + function renderProfiles() { profilesListEl.innerHTML = ""; if (!state.profiles.length) { @@ -171,13 +196,18 @@ function renderProfiles() { ${active ? '当前' : ""}
    ${escapeHtml(p.model)} · ${escapeHtml(p.baseUrl)}
    -
    Key: ${escapeHtml(maskKey(p.apiKey))}
    +
    Key: ${escapeHtml(maskKey(p.apiKey))}${ + p.reasoningEffort + ? ` · 思考: ${escapeHtml(p.reasoningEffort)}` + : "" + }
    + ${capabilityBadge(p.capabilities)}
    ${activateButtonHtml(active, p.id, "activate-profile")} - +
    `; @@ -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" }); diff --git a/web/styles.css b/web/styles.css index 58258da..e748980 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } }