From 211107848367b1391ab0b15c2840b36b54c28d17 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 15 Sep 2026 18:49:00 +0800 Subject: [PATCH 01/49] =?UTF-8?q?=E5=89=8A=E5=87=8F=E7=AD=96=E5=88=92agent?= =?UTF-8?q?=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=B4=9F=E6=8B=85=20(#373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/373 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src-tauri/design-agent/resources/SKILL.md | 18 +- .../resources/skills/architecture.md | 4 +- .../design-agent/resources/skills/concept.md | 2 +- .../design-agent/resources/skills/systems.md | 10 +- .../design-agent/resources/skills/tdd.md | 735 +----------------- .../resources/skills/top_design.md | 2 +- .../src-tauri/design-agent/system-prompt.md | 6 +- .../src-tauri/design-agent/tools.json | 2 +- .../src-tauri/src/agent/design_tools.rs | 132 +++- 9 files changed, 137 insertions(+), 774 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md index ee3cf23b1..aa24d28c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md @@ -2,7 +2,7 @@ 以下为总纲骨架;实际部署时拼接五份分册全文常驻(附录 A): -你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法变成完整可开工的策划产物树:五层文档(概念→顶层→架构→系统×N→技术文档)加速览卡投影——施工方只看技术文档就能做完游戏。【主轴】五层顺序推进:概念→顶层→架构→系统→技术文档;上层未定稿不开下层,定稿以用户检阅确认为准。用户参与度沿层递减:概念层事事确认,技术文档层靠知识与代决。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——从回答中有意识地提炼调性锚(T 原则 3~7 条,每条必须能当 IF-THEN 用),写入概念层第 2 节。此后全项目一切判断先回调性锚级联。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线:概念≤3、顶层≤5,超线先回读调性锚。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编必带"基于系统文档@版本"。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后强制第二遍深化;每层对照量化验收线自查。【产物纪律】概念层一页纸不出现数值按键界面;顶层取舍表每行挂张力编号;架构职责表每行含"不负责→移交谁";技术文档数值全填文本全填资产全行登记——"纯看技术文档能做完游戏"是最终验收;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读调性锚。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 +你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法变成完整可开工的策划产物树:五层文档(概念→顶层→架构→系统×N→技术文档)加速览卡投影——施工方只看技术文档就能做完游戏。【主轴】五层顺序推进:概念→顶层→架构→系统→技术文档;上层未定稿不开下层,定稿以用户检阅确认为准。用户参与度沿层递减:概念层事事确认,技术文档层靠知识与代决。【模板与样例】查看模板或样例时,应根据当前游戏的具体需求和用户实际要求决定产物的字段、章节和展开程度。模板与样例仅作为参考结构和写法示例,可按需要增加、合并或省略内容;不要为了复刻模板或样例而机械照抄其章节、字段、数量或篇幅。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——从回答中有意识地提炼调性锚(T 原则 3~7 条,每条必须能当 IF-THEN 用),写入概念层第 2 节。此后全项目一切判断先回调性锚级联。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线:概念≤3、顶层≤5,超线先回读调性锚。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编必带"基于系统文档@版本"。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后强制第二遍深化;每层对照量化验收线自查。【产物纪律】概念层一页纸不出现数值按键界面;顶层取舍表每行挂张力编号;架构职责表每行含"不负责→移交谁";技术文档数值全填文本全填资产全行登记——"纯看技术文档能做完游戏"是最终验收;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读调性锚。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 - 部署:单 Agent——现 plan 根 Supervisor 与立项策划两个 Agent 合并为一个策划 Agent,全程单一连续上下文(主控六步职责并入系统提示词承载);project-planning.md 整文件替换为本骨架+附录 A 分册拼接(编译期打包路径不变),决策卡渲染与审批等运行时机制沿用 Runtime 代管。 @@ -456,7 +456,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 @@ -544,7 +544,7 @@ P1/P2 可用能力表(能力/说明)控制颗粒度。 --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 01~12 各文件夹的 SKILL.md 与 模板.md 里,按需取用。 --- @@ -594,7 +594,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -607,7 +607,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) @@ -1190,7 +1190,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 @@ -1280,7 +1280,7 @@ P1/P2 可用能力表(能力/说明)控制颗粒度。 --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 01~12 各文件夹的 SKILL.md 与 模板.md 里,按需取用。 --- @@ -1330,7 +1330,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -1343,7 +1343,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md index 5f474d9e7..41dc962ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md @@ -74,7 +74,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 三个接口:**对上**承顶层系统范围表并跑循环覆盖检查;**对内**地图↔职责↔依赖 三方一致、主数据归属唯一;**对下**目录映射 + MVP 闭环喂系统文档站。 -## 四、怎么写(模板即流程,十二节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/architecture.md) ### 1. 架构定位与目标 @@ -85,7 +85,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md index 05c48b19a..1d8f73496 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md @@ -69,7 +69,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 记住三个接口:**对内**锚点仲裁一切;**对下**张力变取舍表、定稿变硬约束; **对上**边界画线防止越层。九节不是清单,是一台咬合的机器。 -## 四、怎么写(模板即流程,九节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/concept-design.md) ### 1. 一句话概念 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md index 554ebbf81..50c44335c 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md @@ -2,7 +2,7 @@ --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 modules/system-types/ 下对应目录的 SKILL.md 与对应模块的模板.md 里,按需取用。 --- @@ -26,9 +26,9 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 的判定部分),读取对应的 `SKILL.md` 与 `模板.md`。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 +3. 该文件夹标注"参考例子"的,可先读例子了解写法。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、常见内容总览:写什么、为什么、怎么咬合 系统文档回答四个问题: **这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ @@ -52,7 +52,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -65,7 +65,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md index cf7faffe5..b2b6e6c50 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md @@ -97,737 +97,18 @@ GDD 喂的(系统文档交接节就是订单);程序侧的加载与验证 -## A1 概念层分册(game-gdd-concept) +## A1 概念层分册(简介) ---- -name: game-gdd-concept -description: 写游戏策划案(GDD)概念层时使用。把一句话游戏想法写成一份 - "一次写对、之后不动"的立项概念文档——它是后续所有设计争议的仲裁依据。 - 任何游戏类型通用。配套:templates/concept-design.md、templates/analysis.md(全局一份)、 - exemplars/stardew-concept.md、exemplars/stardew-analysis.md(全局一份)。 ---- +本分册说明概念设计的目标、边界、核心张力、分析记录和交接要求。完整内容请阅读 `resources/skills/concept.md`;概念设计模板请阅读 `resources/templates/concept-design.md`。 -# 概念层写法(策划 agent · 概念层分册) +## A2 顶层设计分册(简介) -> 本文件是概念层唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +本分册说明顶层循环、资源流、节奏、取舍、范围和验证标准。完整内容请阅读 `resources/skills/top_design.md`;顶层设计模板请阅读 `resources/templates/top-design.md`。 -## 一、这一层的判断立场 -你是资深游戏策划,看过上千份概念案,清楚绝大多数死在"什么都说、什么都不尖"。 -在这个层里你相信: -- 概念的成败在取舍,不在丰富:一句话里卖点只许有一个。 -- 你写的是裁判文档:后续每一层的设计争议,都要能回到这里找到仲裁。 -- 具体压倒抽象:"压力很大"是废字,"每开一扇门都在烧自己的命"才是概念。 -- 用户没说过的话不当他说过:宁可标"待确认",不替人拍板。 -- 发现自己在堆形容词 = 概念没想清楚:停笔回去问,别用空话盖过去。 -- 概念层是"一次写对、之后不动"的层(实作中它的返工率远低于架构与 - 系统层),所以判断力要前置堆足,不要指望后面回来改。 +## A3 系统架构分册(简介) -## 二、动笔前 -1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。 - 没有 → 先问一个定调问题,禁止自问自答充当用户。 -2. 读 exemplars/stardew-concept.md 做质量锚(模仿密度,不抄内容), - 然后往 templates/concept-design.md 里填。 -3. 零参照时在文档头注明"零参照"。 +本分册说明系统职责、依赖、数据归属、MVP 闭环、目录映射和架构校验。完整内容请阅读 `resources/skills/architecture.md`;架构模板请阅读 `resources/templates/architecture.md`。 -## 三、九节总览:写什么、为什么、怎么咬合 +## A4 系统文档分册(简介) -概念文档回答四个问题: -**这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→ -它管到哪、交出什么(8~9)。** - -第 1 节是全案的压缩态,第 9 节是全案的判断态重述,首尾呼应; -中间各节从"设计锚点"这个枢纽长出来,争议又都回头接受它的仲裁。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 一句话概念 | 全案压缩成一句:品类+融合+唯一卖点 | 概念的第一命运是被转述;这句立不住,后面写得再好都救不回来 | 9 是它的重述;2 是它的展开 | -| 2 | 定调与设计锚点 | 定调记录(参照/滑杆/T 原则,调性真源)+ 六个仲裁位:幻想/体验/动机/循环/跑偏/非目标 | 概念层把调定死:后续所有开放问题先回定调记录级联(约八成可就地定),级联不掉的才上决策卡;概念文档的核心职能是当裁判 | **全文档枢纽**:3~6 由它长出;7 由它的循环与动机抽出;定调记录被顶层及以下所有层引用 | -| 3 | 玩家身份与基调 | 玩家在虚构里是谁 + 情绪温度与红线 | 幻想需要一张脸和一种温度,否则是空话;基调边界句防调性漂移 | 身份 = 幻想的具象化;基调 = 目标体验的情绪面 | -| 4 | 风格与世界观 | 支撑玩法的世界规则 + 叙事载体 | 世界观是给玩法供氧的背景板,不是设定集 | 服务 3 的身份与基调;世界规则支撑 2 的核心循环成立 | -| 5 | 目标玩家与情境 | 为谁、什么场景、门槛多高 | 同一设计对不同人是不同游戏;受众映射防止"谁都适合=谁都不适合" | 反面校验 2 的目标体验;情境(一局多久)给 7 的循环定参数 | -| 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 | -| 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 | -| 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" | -| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 | - -咬合一图: - -``` - 1 一句话概念(压缩态) - ↓ 展开 - 2 设计锚点(枢纽 · 仲裁位)◄── 所有节的争议回来找它 - ├→ 3 身份基调 ──→ 4 风格世界观(给玩法供氧) - ├→ 5 目标玩家(反面校验)──→ 6 不是什么(负面收边) - └→ 7 核心张力(动力结构)──→ 【交给顶层】取舍表 - 8 边界与约束(画线:本层到此为止) - ↓ 回环 - 9 概念定稿(判断态重述 + 交接契约) -``` - -记住三个接口:**对内**锚点仲裁一切;**对下**张力变取舍表、定稿变硬约束; -**对上**边界画线防止越层。九节不是清单,是一台咬合的机器。 - -## 四、怎么写(模板即流程,九节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/concept-design.md) - -### 1. 一句话概念 -《__》是一款 __(品类与融合):玩家通过 __,把 __ 逐步 __。 -→ 45~90 字,卖点唯一。检验:删掉那个卖点句子依然成立,说明没写对。 - -### 2. 定调与设计锚点(先定调,再立仲裁位) -**定调记录**(全项目调性真源,此节定死): -- 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。 -- 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。 -- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 - 检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。 - → 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。 -**设计锚点(六项,争议时的仲裁原则,全部具名)** -- 核心幻想:一句描述 + 一句玩家念头(引号写出玩家脑中的自言自语)。 - 检验:念头句写不出来 = 幻想没立住,回去重想,不要用描述糊弄。 -- 目标体验:何时感到什么。 -- 玩家动机:短期 __;长期 __。 -- 核心循环:__ → __ → __ → __ → 回到 __(箭头式)。 -- 跑偏风险:本项目可能的真实偏航,不放万金油。 -- 非目标:一行带过,详表见第 6 节。 - -### 3. 玩家身份与基调 -- 玩家身份:玩家在虚构里是谁 + 本项目的核心节奏,一口气说清。 -- 情绪基调:正面定调 + 边界句——"可以 __,不可以 __"。 - -### 4. 风格与世界观 -世界观为 __(玩法)服务;叙事通过 __(载体)展开。禁编年史、种族志。 - -### 5. 目标玩家与情境(受众映射三件套) -- 与谁的受众重合;吸收了谁的什么需求;**为什么不会变成它**(防串味声明, - 参照越多越必须有这句)。 -- 情境与门槛:单人/多人;一局多久;需要理解 __,不应要求 __。 - -### 6. 不是什么(负面定位表) -| 不是 | 因为 | -→ 每行原因要封死一条具体误会方向(例:不是武器店经营|武器主要拿去 - 战斗,不是卖给顾客)。从锚点的非目标与跑偏风险长出来,通常 4~6 行。 - -### 7. 核心张力 -- __ 有限,但 __。 -- __ vs __(两端的代价各是什么)。 -→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的 - 种子,后面要逐条对应。 - -### 8. 边界与约束 -- 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、 - 系统清单、MVP 内容留给顶层及以后。 -- 规模与回流:单人可维护;所有系统回流核心循环。 -- 参照声明:学组织方式,不复制角色/文本/美术/数值。 - -### 9. 概念定稿(收口重锤) -这个游戏的核心不是 __,而是: -> (一句话重述核心承诺) -交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。 - -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准两问:① 什么是本项目不可替代的核心承诺;② 什么内容扩张会稀释它。 -- 数量纪律:概念期问题通常 ≤3;开始堆第 4 问时先怀疑概念层没想清楚,重读定调记录而不是继续开新争议。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 卖点唯一吗?念头句立得住吗? -- 随便挑一个后续设计问题,锚点六项之一能当裁判吗? -- "不是什么"表封死了最可能的误会方向吗? -- 张力每条都两端有代价吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:出现具体数值、按键、界面即删。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 - - - - -## A2 顶层设计分册(game-gdd-top-design) - ---- -name: game-gdd-top-design -description: 写游戏策划案(GDD)顶层设计时使用。在概念层定稿之后, - 回答"玩家为什么一直玩"——把概念变成可玩的时间结构(循环/资源/取舍/节奏), - 并向架构层交付系统范围。配套:templates/top-design.md、templates/analysis.md(全局一份)、 - exemplars/stardew-top-design.md、exemplars/stardew-analysis.md(全局一份)。 ---- - -# 顶层设计写法(策划 agent · 顶层设计分册) - -> 本文件是顶层设计唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 一、这一层的判断立场 -你是资深游戏策划,正在写全 GDD 最重要的一份文档——概念说"凭什么成立", -顶层说"好玩在哪"。核心循环无趣,后面写再多系统也救不回来。在这个层里你相信: -- 循环优先:先把大循环、小循环、最小体验单位三层跑通,再谈其他一切。 -- 用玩家的手写,不用系统的嘴写:写"玩家在做什么、在想什么", - 不写"系统提供了什么功能"。 -- 每个时间段的痛苦和甜都要有来处:取舍表接概念层的张力,节奏接情绪摆动。 -- 资源守恒直觉:每种资源必问来源、储存、消耗——无来源是白给, - 无消耗是废物,环环相扣成套利。 -- 你不替概念层翻案(张力与定稿已定),也不替架构层拆系统(只划边界)。 - -## 二、动笔前 -1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。 -2. 读 exemplars/stardew-top-design.md 做质量锚(模仿密度,不抄内容), - 往 templates/top-design.md 里填。 -3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。 - -## 三、十六节总览:写什么、为什么、怎么咬合 - -顶层文档回答四个问题: -**玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→ -交给架构什么(12~14)→ 没想清什么、定了什么(15~16)。** - -第 1 节承概念定稿开篇,第 16 节给架构硬约束收口,首尾呼应; -中段三层循环互检,资源流从底下供血。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 | -| 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 | -| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 | -| 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 | -| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 | -| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 | -| 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 | -| 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 | -| 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) | -| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 | -| 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 | -| 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 | -| 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 | -| 14 | 验证标准 | 验证点/成功标准(行为判据) | "好玩"不可测,"玩家能复述循环"可测 | 判据对象=7 的最小体验单位 | -| 15 | 开放问题 | 留给架构前必须想清的 | 显式债务清单 | 进分析文档或架构层开题 | -| 16 | 顶层定稿 | 收口重锤 + 给架构的硬约束(必须__/不得__) | 检验全文档没写散;架构的紧箍咒 | 回环呼应 1;承概念层定稿的接力棒 | - -咬合一图: - -``` -概念层定稿(硬约束 + 张力) - ↓ 承接 -1 定位与规模锚点 ───张力落位───► 9 取舍表(逐条对应) - ↓ 展开 -2 设计目标 → 3 核心推动力 → 4 大循环 ⇄ 5 小循环 ⇄ 7 最小体验单位 - ↓ 供血 -6 资源流与输入输出(防无来源/无消耗/套利) - ↓ 后果侧 -8 活动流程(段落表)→ 10 节奏结构 → 11 失败与回收 - ↓ 交付 -12 系统范围(→架构系统地图的种子)+ 13 范围 + 14 验证标准 - ↓ 收口 -15 开放问题 → 16 顶层定稿(给架构的硬约束) -``` - -三个接口:**对上**承概念定稿、张力逐条变取舍表;**对内**三层循环互检 -(大⇄小⇄最小单位)+ 资源三段全;**对下**系统范围表喂架构的系统地图、 -顶层定稿当架构的紧箍咒、验证标准当原型试玩判据。 - -## 四、怎么写(模板即流程,十六节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md) - -### 1. 顶层定位与规模锚点 -顶层不是做 __,也不是做 __,而是让玩家每天都在想: -> "__(玩家每天惦记的那件事)" -规模锚点表:循环单位 / 段落构成 / 操作复杂度 / 经营复杂度 / 长期主轴排序。 -→ 循环单位先行,定错全盘错。复杂度行可内联参照与"不做"。 - -### 2. 设计目标 -玩家在 __ 循环中同时获得 __、__、__——三者不是并列小游戏,而是互相供给:__。 -→ 检验:砍掉任何一种回报,另外两种是否受伤。 - -### 3. 核心推动力 -- 动机主次:__。 -- 即时推动 __;日程推动 __;季节推动 __;长期推动 __。 -→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。 - -### 4. 大循环 -**__ → __ → __ → __ → 回到 __。**(附核心循环图) -→ 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。 - -### 5. 小循环(具名动词链 ×3+) -**__循环**:__ → __ → __ → __ → __。 -→ 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。 - -### 6. 资源流与输入输出 -(资源流图:每种核心资源 来源 → 储存 → 消耗 三段全) -主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。 -→ 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。 - -### 7. 最小体验单位 -__(多短一段玩法体现独有乐趣——原型只做这一个单位)。 -单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。 - -### 8. 核心活动流程(段落表) -| 阶段 | 玩家行为 | 设计目的 | -→ 设计目的列必填;写不出目的的段落删掉。这份表要能让陌生人复述 -"玩这个游戏的一天"。 - -### 9. 取舍表 -| 决策 | 立即收益 | 延迟收益 | 主要代价 | -→ 每行挂概念层张力编号;避免唯一最优解;不同选择应产生不同但都合理的玩法方式。 - -### 10. 节奏结构 -日内 __ → 周内 __ → 季节/章节 __ → 长期 __。 -整体情绪在"__"与"__"之间摆动(恢复来源 __;变化来源 __)。 - -### 11. 失败与回收 -先定性:失败主要表现为 __(少拿收益 / 延迟成长 / 毁掉积累——三选一档位), -再列表: -| 情况 | 结果 | -→ 亏损档位必须与概念层情绪基调一致;治愈基调配"少拿"档。 - -### 12. 系统范围(架构层接口) -| 系统 | 顶层目的 | 边界(本层不做什么) | -→ 只写目的与边界,不写系统内部规则;每行将来对应架构层一个 Sxx。 - -### 13. 范围与非目标 -最小完整版本包含:__。不做清单:__。 - -### 14. 验证标准 -| 验证点 | 成功标准 | -→ 成功标准必须是行为判据("玩家能复述__""玩家出现__行为"), - "感觉好玩"不算。 - -### 15. 开放问题 -→ 逐条列出;值得跨轮保留的进分析文档,其余留待架构层开题。 - -### 16. 顶层定稿(收口重锤) -顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。 -后续架构必须围绕 __ 拆系统;不得 __。 - -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准两问:① 一天/一局怎样形成清楚但不拖沓的循环;② 风险、收益与长期成长怎样互相支撑。 -- 数量纪律:顶层期问题通常 ≤5(结构性争议天然更多);堆问题时先回读第 1 节定位句。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 三层循环互检了吗:大循环每环有小循环供血?最小单位切得出来? -- 概念层张力每条都在取舍表有对应行吗? -- 每种资源三段全吗(来源/储存/消耗)? -- 验证标准是行为判据吗,还是写了"好玩"? -- 架构层拿到系统范围表能直接开工吗——有没有该划没划的系统? -- 失败档位和概念层基调一致吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 - - - - -## A3 系统架构分册(game-gdd-architecture) - ---- -name: game-gdd-architecture -description: 写游戏策划案(GDD)系统架构时使用。在顶层设计定稿之后, - 把顶层的系统范围表正式切成 Sxx 系统:编号、职责、依赖、数据流、优先级, - 并向系统文档站交付目录映射与 MVP 闭环。配套:templates/architecture.md、 - templates/analysis.md(全局一份)、exemplars/stardew-architecture.md、exemplars/stardew-analysis.md(全局一份)。 ---- - -# 系统架构写法(策划 agent · 系统架构分册) - -> 本文件是系统架构层唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 一、这一层的判断立场 -你是架构师,切系统的刀在你手里。在这个层里你相信: -- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量——每个系统必须能 - 一句话答出"删了它,什么塌"(P0 原因)。 -- **数据所有权唯一**:同一事实只由一个系统维护,其他系统只引用稳定 ID, - 不复制主数据。两个系统管同一件事 = 架构事故。 -- **依赖无环**是硬要求;信息呈现层只读状态、只经行动入口写入。 -- 架构是全项目返工最多的一份(实测 11 版 vs 概念层 2 版)——所以每次改刀 - 都要写变更记录,让"为什么这么切"可追溯。 -- 你不越层:上不重定义玩法循环(那是顶层的),下不写单系统内部规则 - (那是系统文档的),字段定义与数值配置归技术文档层(数值策划)。 - -## 二、动笔前 -1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束** - 摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。 -2. 读 exemplars/stardew-architecture.md 做质量锚(模仿密度,不抄内容), - 往 templates/architecture.md 里填。 -3. 记住顶层的核心循环图——切完必须跑覆盖检查。 - -## 三、十二节总览:写什么、为什么、怎么咬合 - -架构文档回答四个问题: -**这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→ -怎么落地、怎么验证(7~11)→ 还有什么没想清(12)。** - -第 1 节承顶层的定稿约束开篇,MVP 闭环在中间当守门员,开放问题收尾。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 架构定位与目标 | 阶段边界(定哪些系统、不展开内部)+ 划分原则 + 一句话架构 + **变更记录** | 防止架构漂离顶层;改刀可追溯 | 承顶层定稿;变更记录引登记编号 | -| 2 | 系统地图 | Sxx 编号清单(=系统文档目录真源)+ 支撑层 + P0 段五列表(目的/输入/输出/P0原因) | 编号让系统可引用;P0 原因逼答"删了塌什么" | **对下真源**:Sxx ↔ 04 系统文档一一对应 | -| 3 | 系统职责 | 职责表(负责/不负责→移交谁)+ 逐系统说明段 | 边界写死,防两个系统管同一件事 | 系统文档的"边界与非目标"必须与此对齐 | -| 4 | 依赖与数据流 | 依赖图(无环)+ 数据流图 + 主要状态 + 主数据归属规则 | 谁读谁、数据从哪到哪——接口的真源 | 顶层的资源流图在此展开成系统级 | -| 5 | 核心循环覆盖检查 | 顶层每个循环环节 → 认领系统 | 顶层→架构的验收线,防切系统切碎循环 | 对上接口:逐环节对照顶层循环图 | -| 6 | 目录映射 | 职责 → 物理文档目录的归并表 | 职责数≠文档数;归并规则显式化 | **对下接口**:系统文档站照此开工 | -| 7 | MVP 最小闭环 | 编号验证链 + 守门句("闭环不成立不许加东西") | 立项后第一条要跑通的链 | 对应顶层验证标准;失败回顶层而非加系统 | -| 8 | 统一数值基准 | 单位清单 + 四类定性基准(时间/货币/成长/体力风险的风格约束) | 各系统单独配数值会互相失衡;先定全局尺度 | **数值换算与验算归技术文档层**,此处只到定性 | -| 9 | 系统边界 | 哪些功能明确不属于任何系统/归引擎层/归呈现层 | 显式排除,防范围蔓延 | 承概念层"不是什么" | -| 10 | 优先级与范围 | P0/P1/P2 三档(P1/P2 可用能力表) | 拆分≠全做;裁剪顺序显式化 | P0 = MVP 闭环的系统集 | -| 11 | 风险与校验 | 风险/校验方式表 | 架构级风险提前挂出,每条带检验法 | 对应顶层验证标准与概念层跑偏风险 | -| 12 | 开放的结构问题 | 结构级未定案 | 显式债务 | 进分析文档或系统文档开题 | - -咬合一图: - -``` -顶层定稿 + 系统范围表(粗清单) - ↓ 正式切分(拆/并/裁) -1 定位与目标 ──► 2 系统地图(Sxx 真源)──► 3 职责表 - ↓ ↓ ↓ -5 循环覆盖检查 ◄── 4 依赖与数据流(接口真源) - ↓ -6 目录映射 ──► 7 MVP 最小闭环(守门员) - ↓ -8 数值基准(定性)· 9 边界 · 10 优先级 · 11 风险校验 - ↓ -12 开放问题 →(进分析文档 / 系统文档站开题) -``` - -三个接口:**对上**承顶层系统范围表并跑循环覆盖检查;**对内**地图↔职责↔依赖 -三方一致、主数据归属唯一;**对下**目录映射 + MVP 闭环喂系统文档站。 - -## 四、怎么写(模板即流程,十二节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/architecture.md) - -### 1. 架构定位与目标 -本阶段确定"哪些系统支撑一轮玩法",不展开单系统内部规则。 -划分原则:__。一句话架构: -> (玩家通过哪些系统、以什么因果,把一轮玩法的输入变成下一轮的选择) -变更记录:日期 + 改了什么 + 为什么(引登记编号)。 -→ 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 - -### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 -P0 段五列表: -| 系统 | 目的 | 输入 | 输出 | P0 原因 | -→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 - -### 3. 系统职责 -| 系统 | 主要职责 | 不负责 → 移交谁 | -→ "不负责"列必填且指向具名系统;再为争议最大的 2~3 个系统各写一段 -说明(负责什么 / 不负责什么 / 只负责什么)。 - -### 4. 依赖与数据流 -依赖图(mermaid,呈现层用虚线"读取状态")+ 数据流图(资源从产到耗)。 -主要状态:全局/玩家/场景/社会 四类。 -主数据归属规则:规则与数据表分工 / 稳定 ID 关联 / 任何系统不复制他系统主数据。 -→ 依赖图出现环 = 回去重切。 - -### 5. 核心循环覆盖检查 -| 顶层循环环节 | 认领系统 | -→ 逐环节对照顶层循环图;有环节无人认领或多人认领都是切分错误。 - -### 6. 目录映射 -| 目录 | 本阶段定位 | -→ 职责可以归并进同一文档目录(官方版 8 职责→3 文档);归并规则写明。 -系统文档站以此开工:地图上没有的系统不许有文档。 - -### 7. MVP 最小闭环 -1. __ 2. __ …(编号验证链,一条玩家可走的完整因果) -守门句:如果这条闭环不成立,不应继续增加 __。 -→ 闭环失败回顶层改设计,不是加系统打补丁。 - -### 8. 统一数值基准(定性) -全局单位清单(如时间片/游戏日/货币/体力/经验)+ 四类风格约束 -(时间节奏/货币量级感/成长回报取向/体力风险档位)。 -→ 只写到定性;具体换算、验算数值由技术文档层(数值策划)承接。 - -### 9. 系统边界 -明确排除项(不拆出独立 __ 系统 / __ 归引擎层 / __ 归呈现层)。 - -### 10. 优先级与范围 -P0(最小闭环必需):__;P1(完整体验):__;P2(扩展内容):__。 -P1/P2 可用能力表(能力/说明)控制颗粒度。 - -### 11. 风险与校验 -| 风险 | 校验方式 | -→ 从概念层跑偏风险和顶层失败档位反推;校验方式要可观察。 - -### 12. 开放的结构问题 -→ 结构级(接口归属/统一格式/合并拆分)才留这里;数值细节不留。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准问题:结构级争议——接口统一、系统归并、主数据归属划分。(本层原本不配独立分析文件,结构争议全归全局文件本节。) -- 数量纪律:按需;架构期问题多为接口与归属二义。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 每个 Sxx 都能一句话答"删了它什么塌"吗? -- 顶层的循环环节全覆盖、无重复认领吗? -- 依赖图无环?主数据无一物两管? -- 系统文档站拿到目录映射能直接开工吗? -- 有没有字段定义或数值配置偷偷写进来?(该在技术文档层) -- 变更记录补了吗——这次切分和上次的差异说得清吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:向上不翻顶层的案,向下不写系统内部规则,数值字段归技术文档层。 -3. 不凑数:系统数量不是成绩,写不出 P0 原因的系统就是该删的系统。 - - - - -## A4 系统文档分册(game-gdd-system-doc) - ---- -name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 - 红线与分析文档格式。每类系统的专属写法与模板在 modules/system-types/ 下对应目录的 SKILL.md - 与对应模块的模板.md 里,按需取用。 ---- - -# 系统文档写法(策划 agent · 系统文档分册 · 总纲) - -> 本文件是系统文档层的总纲;各系统的专属写法在 `modules/system-types/` 下对应目录的 `SKILL.md`, - 专属模板在 `modules/system-types/` 对应目录的 `模板.md`。通用纪律不在各系统 skill 里重复。 - -## 一、这一层的判断立场 -你是写单个系统的策划。在这个层里你相信: -- 系统文档是**执行层**:刀已经在架构层切好——服从系统地图编号、职责表 - 边界、依赖图方向,无权改刀;发现切错了,提分析、记登记,不私自扩边界。 -- 一个系统文档的成败在**边界节**:"不负责什么、移交给谁"那几行是 - 防返工价值最高的几行。 -- 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。 -- 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。 -- 所有系统同构:读者读熟一份就能读所有份。 - -## 二、动笔前 -1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 -2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 - 的判定部分),读取对应的 `SKILL.md` 与 `模板.md`。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 - -## 三、十二节总览:写什么、为什么、怎么咬合 - -系统文档回答四个问题: -**这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ -它怎么和别人连接、不碰什么(9~12)。** - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 系统目的 | 一句话:删了它什么塌 | 存在性检验 | 架构 P0 原因的展开 | -| 2 | 支撑的玩家体验 | 对应顶层目标第几条 | 防系统自嗨 | 顶层设计目标 ↔ 本系统 | -| 3 | 进入与退出 | 何时进入、何时/如何退出 | 循环的接口时刻 | 顶层的循环环节 | -| 4 | 玩家行动 | 具名动词组 | 玩家用手玩 | 系统类型卡给动词组 | -| 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 | -| 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 | -| 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 | -| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 | -| 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 | -| 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 | -| 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 | -| 12 | 开放问题 | 本系统未定案 | 显式债务 | 进分析文档 | - -咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 -职责边界;**对下**第 7 节交接喂 TDD。 - -## 四、十二节通用写法 -(各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) - -1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 -2 支撑体验:对应顶层目标第__条、调性原则第__条。 -3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。 -4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。 -5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。 -6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。 -7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。 -8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 -9 内部循环:动词链;可拆单次/区域/长期三层。 -10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 -12 开放问题:结构级才留;手感数值类标"待原型验证"。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准问题:① 本系统与相邻系统的边界在哪;② 本系统内部哪个规则影响顶层取舍。条目标系统号(如 S06)。 -- 数量纪律:按需;每系统通常 0~1 条,超了先回读架构职责表。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 目的一句话成立吗?边界节和架构职责表逐行对齐吗? -- 输入输出和依赖图逐边对上吗?有没有泛称漏网? -- 状态是枚举还是散文?失败路径给了原因和恢复吗? -- 有没有字段或数值偷偷写进来?(该在 TDD) -- 同构检查:另一份系统文档的读者能按同样方式读这份吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD), - 不替别的系统定规则。 -3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。 - - - - -## A5 技术文档分册(game-tdd) - ---- -name: game-tdd -description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿后的第五步:把 - "怎么做"写实——程序怎么写、美术怎么做、字段怎么定义、怎么配表。 - 三大件各有专属分册:技术实现(程序侧)/ 美术圣经(美术侧)/ 数据与配表(数据侧)。 ---- - -# 技术文档写法(策划 agent · TDD 分册 · 总纲) - -> 本文件是 TDD 层唯一承载写作流程的教学件;各分册 SKILL 与模板配套使用。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 〇、TDD 的完成判据(总纲) - -**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。** -GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。 -检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、 -每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口, -缺口回 GDD 同步后**收编**进 TDD(带版本锁)。收编是构建期快照:GDD 定稿 -变更 → 触发对应收编节重同步(与 fast_gdd 投影同一机制,方向相反)。 - -## 一、这一层的判断立场 - -你是工程师思维的策划。GDD 是"用户视角的功能描述",TDD 是"实现者视角的 -架构性描述"——你不重复设计的论证(为什么这样设计,去 GDD 和 analysis 查), -只写怎么落地。你相信: - -- **交接契约是 TDD 最大的价值**:美术交给程序的素材、程序读的表、加载的 - 顺序——每一条缝都写死。缝上不写死,返工就在缝里发生。 -- **平台事实优先**:目标运行时由 GDD 平台事实锁定——**HTML / Unity / Godot / - Cocos 四选一**。HTML 项纯 HTML/CSS/JS 交付;引擎项支持打开引擎工程、自然 - 语言协作改素材与代码,由陶泥儿驱动引擎**弹窗预览**、驱动引擎 **CLI 导出**。 - 一切技术选择先过所选运行时这道闸,不推荐该运行时做不出来的东西; - TDD 不擅自换运行时。 -- **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统, - 其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。 -- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测 - 验证过,照抄。 -- **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都 - 不做"做完一大批才发现不对"的事。 - -## 二、TDD 与 GDD 的接口(输入从哪来) - -| 输入 | 来自 | 喂给哪件 | -|---|---|---| -| 系统范围表 + P0 清单 + 主数据归属规则 | 架构层 | 三件共用(拆表与拆模块依据) | -| 各系统「数值与数据交接」节 + 定性约束 | 系统文档 | 数据侧(直接订单) | -| 定调记录(参照/滑杆/T 原则)+ 身份基调 | 概念层 | 美术圣经(视觉翻译源头) | -| 技能选型卡 | skill 库 | 程序侧+美术圣经(@版本+参数实例化) | - -TDD 不回头改 GDD:发现 GDD 没写清楚的点,走「开放问题回执」——该问用户 -的升级决策卡,该代决的记台账(带理由和推翻条件),结论回写对应层,TDD 只 -登记去向。顾问期(开发阶段)同一出口:程序美术卡点、成品与文档偏差,都从 -回执进、修订出(v{N+1})。 - -## 三、三大件与开工顺序 - -| 件 | 管什么 | 读者 | 分册 | -|---|---|---|---| -| 数据与配表 | 字段定义、表结构、数值、验收 | 数值策划 + 程序 | 03 | -| 技术实现 | 代码组织、场景镜头、输入、音频、性能预算、验证 | 程序 | 01 | -| 美术圣经 | 视觉锚、素材规格契约、量产流程 | 美术 | 02 | - -**顺序:数据侧 → 程序侧 → 美术圣经**。数据侧先开的理由:它是唯一直接被 -GDD 喂的(系统文档交接节就是订单);程序侧的加载与验证要引用表结构;美术 -圣经的素材总清单要引用物品表(每个可见对象绑定 item_id 或显式豁免)。小型 -项目三件可交叉,但**表结构永远先于数值填充**。 - -## 四、怎么写(总纲级;细节在各分册) - -1. 数据侧:总清单拆表 → ID 与字段字典 → 公共条件表 → 建表顺序(物品表 - 起步)→ 表结构契约(程序签名)→ 数值填充(代决+台账)→ 验收七查。 -2. 程序侧:系统实现总览(每系统一段话写死怎么做)→ 技术选型与 skill 引用 - → 场景与镜头 → 输入与操作 → 音频 → 验证方式与性能预算。 -3. 美术圣经:视觉锚(从概念层定调翻译)→ 素材规格契约逐素材一行 → - 量产流程(概念候选→锚点确认→小批→验收→扩产)→ 资产总清单。 - -## 五、写完自查(参考,不是闸门) - -- 任意一条缝(美术→程序、表→代码、表→表引用)是否都写死了规格? -- 每张表是否答得出"谁是拥有者系统"?每个 ID 是否全局唯一? -- 程序侧验证方式是否可执行(跑什么命令、看什么输出)? -- 素材契约是否覆盖了 GDD 里全部可见对象(或显式豁免)? -- 验收是否跑过且无 blocker? - -## 六、红线(只有四条) - -1. **收编必带版本锁**:从 GDD 收编的任何内容标注"基于系统文档@v{N}"; - 无锁收编=违规(双源漂移之源)。TDD 不产生设计观点,只汇集与落实施工。 -2. 引用必带版本:skill 引用必须 `名字@版本 + 实例化参数`,选型时与执行时 - 用的一致性靠此保证。 -3. 不越权拍板:产品级取舍回 GDD 层走决策流程;TDD 只做技术代决且记台账。 -4. 表里不写散文:单元格只有数据和枚举;规则写在契约文档,不写在表里。 +本分册说明单个系统的职责、规则、输入输出、反馈、边界、验证和分析记录。完整内容请阅读 `resources/skills/systems.md`;系统类型的专属写法和模板请按需阅读 `modules/system-types/` 下对应分册。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md index 0f720e181..0eeba8577 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md @@ -80,7 +80,7 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 (大⇄小⇄最小单位)+ 资源三段全;**对下**系统范围表喂架构的系统地图、 顶层定稿当架构的紧箍咒、验证标准当原型试玩判据。 -## 四、怎么写(模板即流程,十六节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md) ### 1. 顶层定位与规模锚点 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md index 0850865e8..3807f565e 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md @@ -1,12 +1,16 @@ 你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料;所有文件路径使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。修改文件后,简要说明修改内容和相对路径。对不确定内容区分用户确认、Agent 建议和待原型验证事项;不要把建议写成用户已确认的决定。 -优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答,并据此更新相关产物;不要带着这类未决问题提交阶段审批。 +优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答。决定稳定后,再更新受影响的正式产物和必要的过程记录;不要带着这类未决问题提交阶段审批。 + +分析阶段优先记录当前目标、上层约束、候选方案、取舍、用户已确认或 Agent 暂定的边界,以及必须检查的验收项。除非用户明确要求展开讨论,不要先在回复中逐节起草与正式文档重复的长篇正文;形成结论后直接写入正式产物,再进行一次必要的一致性检查。文件操作前只需说明简短计划、目标文件和主要变化。 正式策划文档在文档头部写明版本标记,例如“版本:v1”。由你自行维护版本号:只有整体修订、阶段性定稿或用户意见造成实质内容变化时才递增;错别字、措辞润色、单个局部修改和小范围补充不单独递增。 阶段审批是每个阶段的最终检查,表示本阶段产物已经完成,无未决内容,交给用户做最终检阅,不承担问询功能。提交前,解决所有影响本阶段完成的关键问题,或明确说明它们不阻塞本阶段交付,并更新相关产物。可以保留不阻塞当前阶段的后续事项和待原型验证项。 +过程文档用于记录关键依据、决定和待办,不要求实时完整,也不应重复正式设计文档。阶段内优先完成主要设计内容;只有稳定且影响后续工作的决定才需要同步到多个过程文档。阶段提交前,补齐影响验收的关键记录。 + 阶段获批后,产物中已经采用的方案作为后续工作的依据,并保留原有决策来源。除非用户主动质疑或出现新的约束冲突,不要反复要求确认历史暂定决定。 用户说“继续”时,继续推进当前阶段最有价值的工作。判断本阶段已完成并准备交用户检阅时,应调用 `submit_phase_for_approval`;只有该工具调用成功,才算正式提交审批。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index 06c643c6b..c9c1bd6cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件,优先用于已有文件的小范围修订。先读文件,以唯一且非空的 old_text 精确匹配并替换为 new_text;new_text 为空可删除片段,保留原文并追加可插入。匹配失败不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["path","old_text","new_text"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 6d65f9dd3..5221030b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -264,22 +264,45 @@ pub(crate) fn execute_design_file_tool( } "patch_file" => { let relative = required_tool_path(args)?; - let old = args - .get("old_text") - .and_then(Value::as_str) - .ok_or("缺少 old_text")?; - let new = args - .get("new_text") - .and_then(Value::as_str) - .ok_or("缺少 new_text")?; - if old.is_empty() { - return Err("old_text 不能为空".to_string()); - } + let edits = if let Some(items) = args.get("edits").and_then(Value::as_array) { + if items.is_empty() { + return Err("edits 不能为空".to_string()); + } + items + .iter() + .enumerate() + .map(|(index, item)| { + let old = item + .get("old_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].old_text 必须是字符串"))?; + let new = item + .get("new_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].new_text 必须是字符串"))?; + if old.is_empty() { + return Err(format!("edits[{index}].old_text 不能为空")); + } + Ok((old.to_string(), new.to_string())) + }) + .collect::, String>>()? + } else { + let old = args + .get("old_text") + .and_then(Value::as_str) + .ok_or("缺少 old_text")?; + let new = args + .get("new_text") + .and_then(Value::as_str) + .ok_or("缺少 new_text")?; + if old.is_empty() { + return Err("old_text 不能为空".to_string()); + } + vec![(old.to_string(), new.to_string())] + }; let (display, path) = resolve_design_workspace_path(root, &relative)?; if !path.is_file() { - return Ok(Value::String(format!( - "局部修改失败:文件不存在:{display}" - ))); + return Err(format!("文件不存在:{display}")); } let content = fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?; @@ -288,20 +311,52 @@ pub(crate) fn execute_design_file_tool( } else { "\n" }; - let old = old.replace("\r\n", "\n").replace('\n', newline); - let new = new.replace("\r\n", "\n").replace('\n', newline); - let count = content.matches(&old).count(); - if count != 1 { - return Err(format!( - "原文匹配 {count} 处,需要唯一匹配;请重新读取文件并扩大匹配范围" - )); + let normalized = edits + .into_iter() + .map(|(old, new)| { + ( + old.replace("\r\n", "\n").replace('\n', newline), + new.replace("\r\n", "\n").replace('\n', newline), + ) + }) + .collect::>(); + let mut matches = Vec::new(); + for (index, (old, new)) in normalized.iter().enumerate() { + let count = content.matches(old).count(); + if count == 0 { + return Err(format!("edits[{index}] 原文未找到:{display}")); + } + if count != 1 { + return Err(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + )); + } + let start = content.find(old).expect("count checked"); + let end = start + old.len(); + if let Some((other_index, _other_start, _other_end)) = matches + .iter() + .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) + { + return Err(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + )); + } + matches.push((index, start, end)); + let _ = new; } - crate::write_game_creator_private_file( - &path, - content.replacen(&old, &new, 1).as_bytes(), - "策划工作区文件", - )?; - Ok(Value::String(format!("已局部修改 {display}"))) + let mut updated = content.clone(); + for (index, start, end) in matches.into_iter().rev() { + let (_, new) = &normalized[index]; + updated.replace_range(start..end, new); + } + if updated == content { + return Err(format!("没有产生修改:{display}")); + } + crate::write_game_creator_private_file(&path, updated.as_bytes(), "策划工作区文件")?; + Ok(Value::String(format!( + "已局部修改 {display}({} 处)", + normalized.len() + ))) } "delete_path" => { let relative = required_tool_path(args)?; @@ -645,6 +700,29 @@ mod tests { ) .expect("patch"); assert!(patched.as_str().unwrap().contains("已局部修改")); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/multi.md","content":"甲\n乙\n丙"}), + ) + .expect("write multi"); + let multi = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/multi.md", + "edits":[ + {"old_text":"甲","new_text":"一"}, + {"old_text":"丙","new_text":"三"} + ] + }), + ) + .expect("multi patch"); + assert!(multi.as_str().unwrap().contains("2 处")); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/multi.md")).expect("read multi"), + "一\n乙\n三" + ); execute_design_file_tool(root, "delete_path", &json!({"path":"notes"})) .expect("delete dir"); assert!(!root.join("design_artifacts/notes").exists()); From 687bad0adfe27209ac228c11d55270f927ba4818 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 19:27:14 +0800 Subject: [PATCH 02/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E6=8F=90=E7=A4=BA=20(#374)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录失败时,部分网关或兼容接口返回字符串形式的 error,前端解析失败后只显示通用“登录失败”,用户无法判断具体原因。 本次修改: - 统一错误解析器支持字符串 error,并保留标准嵌套错误的优先级。 - 增加登录接口返回“手机号或密码错误”的回归测试。 - 同步认证排障记录。 验证: - API 客户端测试 32 项通过 - 认证服务与登录弹窗测试 55 项通过 - npm run typecheck 通过 - npm run lint:eslint 通过 - npm run check:encoding 通过 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/374 Co-authored-by: kdletters Co-committed-by: kdletters --- docs/project-memory/shared-memory/pitfalls.md | 7 +++++ packages/shared/src/http.ts | 30 ++++++++++++++----- src/services/apiClient.test.ts | 26 ++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 00c1fa366..35e266485 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5596,3 +5596,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。 - 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。 - 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。 + +## 2026-09-15 登录失败提示必须保留接口返回原因 + +- **现象**:账号登录失败时页面只显示“登录失败”,用户无法判断是手机号、验证码、密码还是服务状态问题。 +- **原因**:统一错误解析器只处理标准 `error.message/details` 结构;部分网关或旧兼容响应使用字符串 `error`,解析失败后回落到登录接口传入的通用文案。 +- **处理**:`parseApiErrorMessage` 同时支持字符串 `error`,标准嵌套结构保持原有优先级;未知或空响应继续使用通用兜底。 +- **验证**:`src/services/apiClient.test.ts` 新增字符串错误响应回归用例,定向测试 32 项通过,`npm run typecheck` 通过。 diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 9aa01fafa..84e73fa8e 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -177,23 +177,34 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { const parsed = JSON.parse(rawText) as | ApiErrorResponse | { - error?: { - message?: string; - code?: string; - details?: Record | null; - }; + error?: + | string + | { + message?: string; + code?: string; + details?: Record | null; + }; message?: string; code?: string; }; - const detailMessage = readApiErrorDetailMessage(parsed.error?.details); + const detailMessage = + typeof parsed.error === 'object' && parsed.error !== null + ? readApiErrorDetailMessage(parsed.error.details) + : ''; if (detailMessage) { return detailMessage; } + if (typeof parsed.error === 'string' && parsed.error.trim()) { + return parsed.error.trim(); + } + if ( - typeof parsed.error?.message === 'string' && + typeof parsed.error === 'object' && + parsed.error !== null && + typeof parsed.error.message === 'string' && parsed.error.message.trim() ) { return parsed.error.message.trim(); @@ -209,7 +220,10 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { } const errorCode = - typeof parsed.error?.code === 'string' && parsed.error.code.trim() + typeof parsed.error === 'object' && + parsed.error !== null && + typeof parsed.error.code === 'string' && + parsed.error.code.trim() ? parsed.error.code.trim() : 'code' in parsed && typeof parsed.code === 'string' && diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index c1021f0d0..f4149b26e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1031,6 +1031,32 @@ describe('apiClient', () => { }); }); + it('preserves a concrete reason from legacy string error responses', async () => { + fetchMock.mockResolvedValueOnce( + createResponseMock({ + status: 401, + body: JSON.stringify({ error: '手机号或密码错误' }), + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + await expect( + requestJson( + '/api/auth/entry', + { + method: 'POST', + }, + '登录失败', + { skipAuth: true, skipRefresh: true }, + ), + ).rejects.toMatchObject({ + message: '手机号或密码错误', + status: 401, + }); + }); + it('prefers api error details.reason over details.message for diagnostics', async () => { setStoredAccessToken('details-reason-first-token', { emit: false }); fetchMock.mockResolvedValueOnce( From 29e84a77d1bf94ea656f31f635831d6cb3baf57e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 19:43:35 +0800 Subject: [PATCH 03/49] =?UTF-8?q?=E5=88=87=E6=8D=A2=20Tiantoken=20?= =?UTF-8?q?=E5=B9=B6=E5=88=A0=E9=99=A4=E6=97=A7=20Vidu=20=E9=9F=B3?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路 保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现 同步脚本、文档、测试与运行时 provider 路由 --- .env.example | 15 +- deploy/container/api-server.env.example | 2 +- deploy/env/api-server.env.example | 6 +- .../shared-memory/decision-log.md | 11 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 5 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +- ...编辑器】画板音乐生成入口设计-2026-06-18.md | 6 +- scripts/bgfilter-worker-load-smoke.test.mjs | 6 +- scripts/check-api-server-env.mjs | 4 +- scripts/container-worker-smoke.mjs | 6 +- scripts/export-match3d-resource-pipeline.mjs | 16 +- ...ate-edutainment-road-town-map-concepts.mjs | 14 +- ...te-edutainment-toca-world-map-concepts.mjs | 14 +- .../make-taonier-hand-spirit-transparent.mjs | 14 +- scripts/test-ve-llm.mjs | 15 +- server-rs/crates/api-server/src/config.rs | 60 ++++- .../src/editor_screen_background_decision.rs | 38 ++-- .../src/external_generation_worker.rs | 6 +- .../api-server/src/openai_image_generation.rs | 21 +- server-rs/crates/api-server/src/state.rs | 37 ++- .../handlers.rs | 215 ------------------ .../vector_engine_audio_generation/publish.rs | 6 +- .../vector_engine_audio_generation/tasks.rs | 38 ---- .../vector_engine_audio_generation/tests.rs | 212 ----------------- .../vector_engine_audio_generation/types.rs | 4 - server-rs/crates/platform-audio/src/client.rs | 59 +---- server-rs/crates/platform-audio/src/lib.rs | 13 +- .../crates/platform-audio/src/persist.rs | 7 - .../crates/platform-audio/src/request.rs | 54 +---- server-rs/crates/platform-audio/src/types.rs | 25 -- .../tests/vector_engine_audio.rs | 103 +-------- src/services/apiClient.test.ts | 20 +- 32 files changed, 217 insertions(+), 843 deletions(-) delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs delete mode 100644 server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs diff --git a/.env.example b/.env.example index d8988060b..bf06357e1 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Server-side OpenAI-compatible LLM endpoint base URL. -LLM_BASE_URL="https://api.vectorengine.cn/v1" +LLM_BASE_URL="https://api.tiantoken.com/v1" # Server-side API key used by the local Vite proxy. -# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY` +# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY` # through the Rust api-server proxy. # Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy, # but it should not be relied on by browser code. @@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" # Model name for chat completions. VITE_LLM_MODEL="gpt-5.4-mini" GENARRATIVE_LLM_PROVIDER="openai-compatible" -GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1" +GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1" GENARRATIVE_LLM_API_KEY="" GENARRATIVE_LLM_MODEL="gpt-5.4-mini" @@ -130,10 +130,15 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" -# VectorEngine LLM and GPT-image-2 / Gemini image generation config. +# Tiantoken LLM and GPT-image-2 / Gemini image generation config. +TIANTOKEN_BASE_URL="https://api.tiantoken.com" +TIANTOKEN_API_KEY="" +TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000" + +# VectorEngine is retained for Suno audio generation only. VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn" VECTOR_ENGINE_API_KEY="" -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000" +VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000" # ElevenLabs editor sound-effect generation is server-side only. ELEVENLABS_BASE_URL="https://api.elevenlabs.io" diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 6dddc3e82..b4e0286b1 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -62,7 +62,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 GENARRATIVE_LLM_PROVIDER=openai-compatible -GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1 +GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1 GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini WECHAT_MINIPROGRAM_MESSAGE_TOKEN= diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index dd543cd4f..9b508dac1 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -79,7 +79,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 GENARRATIVE_LLM_PROVIDER=openai-compatible -GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1 +GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1 GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini # LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。 @@ -87,9 +87,11 @@ GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token +TIANTOKEN_BASE_URL=https://api.tiantoken.com +TIANTOKEN_API_KEY= +TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn VECTOR_ENGINE_API_KEY= -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 ELEVENLABS_BASE_URL=https://api.elevenlabs.io ELEVENLABS_API_KEY= diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d35c4436d..d18fbd8ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8740,3 +8740,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(面板可关 + 非模态任务面板):两块生成浮层在提交期间放开 × / 遮罩 / Esc,提交按钮旁给「后台运行并关闭」;**关闭 ≠ 取消**(表单的 `await` 挂在该任务的终局上,不是面板生命周期)。新增「生成任务」非模态浮层(不铺遮罩、不做焦点陷阱、**不进** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 遮挡判据),入口按钮 `aria-label="生成任务"`;已完成的条目按 `assetId` 复用既有 `pendingResourceFocusRef` 聚焦链定位素材卡。 - 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。 - 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。 + +## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken + +- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。 +- 实现边界:api-server 在创建状态时冻结 Tiantoken 配置,LLM、图片和旧版非 Suno 音频按该配置路由;Suno 的提交 / 轮询仍使用旧 VectorEngine 配置。旧 `vector_engine_*` 测试构造保留为 Tiantoken fallback,生产新环境变量优先。 +- 验证:Tiantoken `/v1/models` 返回 HTTP 200(126 个模型,含 `gpt-image-2`、`gpt-5.4-mini`);api-server Tiantoken 配置单测、platform-audio 全量测试、图片定向测试、前端 `apiClient` 定向测试、`npm run typecheck`、`npm run check:api-server-env`、编码 / fmt / diff 检查通过。未对音频上游提交生成任务,模型列表未列出 audio / Vidu 条目。 + +## 2026-09-15 删除旧版 Vidu 音效实现 + +- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。 +- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL`、`AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index becec30de..f21c0516b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -258,8 +258,9 @@ npm run check:server-rs-ddd - 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 - 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,并由客户端 GET-only 对账;本次请求不得附加 `resultPersistenceStarted`。`AlreadyApplied` 仅在 early guard 与最终 procedure 并发相遇时作为底层幂等兜底,复用既有 commit 且不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 - 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 -- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent 文本链路使用 Tiantoken,使用 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 Tiantoken base URL 规范化到 `/v1`。VectorEngine 只保留给 Suno 音乐任务;后续排障时优先确认 Tiantoken `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 +- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`,通用 `/api/llm/chat/completions` 代理可使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时由 Tiantoken 凭据承接。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent 客户端来源。 +- 当前 provider 路由:`TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 承载原 VectorEngine 的文本和图片能力;旧版 Vidu 音效代码已移除;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐及 Suno 音效任务;新编辑器 SFX V2 继续独立使用 ElevenLabs。 ### 平台适配器:`platform-llm` 公共能力与三协议工具契约 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7855d1a72..066bc0a5d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -214,7 +214,7 @@ spacetime sql "SELECT * FROM runtime_setting LIMIT 1" --server http:/ 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`,CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;其它版本可执行 `spacetime version install && spacetime version use `,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL`、`TIANTOKEN_API_KEY` 和 `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`,`api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL`、`ELEVENLABS_API_KEY` 和 `ELEVENLABS_REQUEST_TIMEOUT_MS`,timeout 默认 `180000ms`;base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`;Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。 @@ -790,14 +790,14 @@ PowerShell 下按测试文件头部示例依次设置三个必填变量,并按 该用例只从进程环境变量读取凭据,不读 `.env.secrets.local`,也不会写入任何文件。真实 API Key 一律不得提交进仓库,也不要写进 `docs/`、脚本默认值或测试 fixture;临时密钥用完应在上游及时吊销。 -创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行: -创意 Agent `gpt-5.4-mini` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。排查或切换密钥后,可在本地运行: +创意 Agent 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于文本协议。排查或切换密钥后,可在本地运行: +创意 Agent `gpt-5.4-mini` 文本链路使用 Tiantoken:`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 Tiantoken 凭据。排查或切换密钥后,可在本地运行: ```bash node scripts/test-ve-llm.mjs ``` -该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL` 和 `VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。 +该脚本读取仓库根目录 `.env.secrets.local` 中的 `TIANTOKEN_BASE_URL` 和 `TIANTOKEN_API_KEY`,依次探测 `/v1/models`、`/v1/chat/completions`、`/v1/responses`、`gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。 ### 手机验证码短信 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index b81239a14..7e5133dd9 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -30,7 +30,7 @@ SFX V2 已完成产品与技术口径冻结及 T0–T6 工程实施;这不表 - `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前按 ECMAScript `String.trim()` 语义只删除首尾空白和行终止符,包含 `U+FEFF`;不改写内部空白、换行、标点、零宽字符或 Unicode 形式。首尾 `U+0085` 不属于该删除集合。按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。 - `actualPrompt`:Worker 在正式生成时对冻结的 `userPrompt` 进行统一英文化并严格验收后得到的英文 Prompt,继续复用持久化字段 `actual_prompt`。前端不提交 `actualPrompt`,ElevenLabs 只接收验收通过的 `actualPrompt`。 -- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示和其它未迁移调用方的兼容标识,不是新编辑器 SFX 任务的 alias 或 fallback。 +- `model`:新任务固定 `eleven_text_to_sound_v2`,UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示标识,不是新编辑器 SFX 任务的 alias 或 fallback。 - `duration`:`null` 表示自动时长,有限数值表示手动时长。首次打开面板默认处于自动模式,并预置最近手动值为 `5s`;手动范围 `0.5-30s`,UI 步进 `0.1s`。自动模式禁用 slider 但保留最近手动值,关闭自动后恢复该值。服务端只校验有限值与范围,不把 UI 步进扩大成 provider 精度限制。 - `loop`:独立布尔参数,默认 `false`,由页面开关原样冻结并传入 ElevenLabs。Prompt 是自由文本,系统不从 Prompt 推断、同步或校验 Loop;Prompt 文本与 Loop 开关不建立业务一致性门禁。 - `prompt_influence`:服务端固定 `0.3`,不向前端开放滑杆或请求字段。输出格式固定为 query `output_format=mp3_44100_128`。 @@ -468,7 +468,7 @@ POST / api / editor / audios / background - music / prompts / simplifications; - 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。 - Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。 - 新编辑器音效使用独立 ElevenLabs 直接二进制 adapter,不伪装成 Vidu / Suno 的 submit + poll 任务。adapter 负责 endpoint 归一、`xi-api-key` header、固定 query / body、单次 POST、有界二进制读取、MP3 验证和时长探测。 - - Vidu `audio1.0` 的 body builder、轮询和下载能力仅保留给历史展示和其它未迁移调用方;新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。 + - 旧版 Vidu `audio1.0` 的 body builder、轮询和下载代码已移除;历史 `audio1.0` 只读展示,新 `audio-sound-effect` 任务不进入 `/ent/v2/text2audio` 或 `/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`。 - Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路;`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时。 - VectorEngine 音频响应的 `code` 需要兼容 `"success"`、`"ok"`、`"0"`、`"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。 - 在 `api-server/src/vector_engine_audio_generation/generation.rs` 原地演进现有编辑器音频 generation handler;以下正式路由保持原路径和现有注册,不新增平行 BFF: @@ -526,7 +526,7 @@ POST / api / editor / audios / background - music / prompts / simplifications; - BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。 - BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。 - 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。 -- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;历史和其它未迁移 Vidu 调用方的 builder / 轮询能力保持可用,但新编辑器 SFX 任务只调用 ElevenLabs。 +- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;旧版 Vidu builder / 轮询能力已移除,历史素材只读展示,新编辑器 SFX 任务只调用 ElevenLabs。 - `audio-sound-effect` 与 `audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 的补全 / 简化、Suno 模型和 BGM 字符规则;BGM 不得渲染 SFX 的一键优化、Loop、ElevenLabs 模型和 SFX 时长控件。两个 mode 相互切换时,菜单、预设滚动、锁、快照和助手状态不得跨分支泄漏。 - SFX V2 翻译失败时 ElevenLabs 请求数为 0;成功时每个平台 job 最多一次 ElevenLabs POST,`prompt / actual_prompt`、实际时长、Loop、model、provider 和 Task ID 在队列、素材、画布、响应、刷新和重绘后保持权威一致。 - SFX 两类 LLM 请求契约测试分别断言:一键优化每次请求为 Luna + Medium + `max_completion_tokens = 8192`,翻译每次业务尝试为 Luna + Low + `max_completion_tokens = 8192`,两者都不含 `max_tokens`、`max_output_tokens` 或 temperature;测试命名和说明必须把 `8192` 解释为包含 reasoning 的 completion tokens 总预算。优化遇到 `finish_reason = length` 直接失败且不写回;翻译首轮 `length` 只重试一次,第二轮 `length` 最终失败且 ElevenLabs 请求数为 0。 diff --git a/scripts/bgfilter-worker-load-smoke.test.mjs b/scripts/bgfilter-worker-load-smoke.test.mjs index 635c09cfd..919dedcef 100644 --- a/scripts/bgfilter-worker-load-smoke.test.mjs +++ b/scripts/bgfilter-worker-load-smoke.test.mjs @@ -24,7 +24,7 @@ describe('bgfilter worker smoke harness', () => { GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token', GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token', PATH: '/safe/bin', - VECTOR_ENGINE_API_KEY: 'real-vector-secret', + TIANTOKEN_API_KEY: 'real-tiantoken-secret', }, providerBaseUrl: 'http://127.0.0.1:19001', tempRoot: '/tmp/bgfilter-load-smoke-test', @@ -40,10 +40,10 @@ describe('bgfilter worker smoke harness', () => { assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid'); assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret'); assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined); - assert.equal(env.VECTOR_ENGINE_API_KEY, undefined); + assert.equal(env.TIANTOKEN_API_KEY, undefined); assert.ok(!Object.values(env).includes('real-internal-token')); assert.ok(!Object.values(env).includes('real-provider-token')); - assert.ok(!Object.values(env).includes('real-vector-secret')); + assert.ok(!Object.values(env).includes('real-tiantoken-secret')); }); test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => { diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs index 8b4d24e5f..21e8f3cf6 100644 --- a/scripts/check-api-server-env.mjs +++ b/scripts/check-api-server-env.mjs @@ -1,8 +1,8 @@ import { mergeApiServerEnv } from './dev-utils.mjs'; const REQUIRED_FOR_PUZZLE_GENERATION = [ - 'VECTOR_ENGINE_BASE_URL', - 'VECTOR_ENGINE_API_KEY', + 'TIANTOKEN_BASE_URL', + 'TIANTOKEN_API_KEY', 'ALIYUN_OSS_BUCKET', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index 881224570..1beed7036 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -514,11 +514,11 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2 GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15 GENARRATIVE_LLM_PROVIDER=openai-compatible -GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1 +GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1 GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini -VECTOR_ENGINE_BASE_URL= -VECTOR_ENGINE_API_KEY= +TIANTOKEN_BASE_URL= +TIANTOKEN_API_KEY= ALIYUN_OSS_BUCKET= ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com ALIYUN_OSS_ACCESS_KEY_ID= diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs index 1f192395f..872f1b8d6 100644 --- a/scripts/export-match3d-resource-pipeline.mjs +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -52,12 +52,12 @@ function timestamp() { function resolveEnv() { const env = mergeApiServerEnv(repoRoot, process.env); return { - baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(env.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(env.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(env.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; @@ -160,12 +160,12 @@ async function fetchJson(url, options, timeoutMs) { }); const text = await response.text(); if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -202,7 +202,7 @@ async function imageBytesFromPayload(payload, env) { if (b64Images[0]) { return Buffer.from(b64Images[0], 'base64'); } - throw new Error('VectorEngine returned no image'); + throw new Error('Tiantoken returned no image'); } async function generateImage(env, { prompt, negativePrompt, size, outPath }) { @@ -320,7 +320,7 @@ async function main() { { mode: 'dry-run', outDir, - message: '加 --live 才会真实调用 VectorEngine。', + message: '加 --live 才会真实调用 Tiantoken。', prompts, }, null, @@ -332,7 +332,7 @@ async function main() { const env = resolveEnv(); if (!env.baseUrl || !env.apiKey) { - throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY'); + throw new Error('Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY'); } console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`); diff --git a/scripts/generate-edutainment-road-town-map-concepts.mjs b/scripts/generate-edutainment-road-town-map-concepts.mjs index 84d8264c6..14c181782 100644 --- a/scripts/generate-edutainment-road-town-map-concepts.mjs +++ b/scripts/generate-edutainment-road-town-map-concepts.mjs @@ -127,12 +127,12 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; @@ -249,12 +249,12 @@ async function fetchJson(url, options, timeoutMs) { }); const text = await response.text(); if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -335,7 +335,7 @@ async function generateOne(env, concept, size, references) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${concept.id}`); + throw new Error(`Tiantoken returned no image for ${concept.id}`); } mkdirSync(outDir, { recursive: true }); @@ -401,7 +401,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/scripts/generate-edutainment-toca-world-map-concepts.mjs b/scripts/generate-edutainment-toca-world-map-concepts.mjs index c6bce5c54..ed9f37ab0 100644 --- a/scripts/generate-edutainment-toca-world-map-concepts.mjs +++ b/scripts/generate-edutainment-toca-world-map-concepts.mjs @@ -107,12 +107,12 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; @@ -226,12 +226,12 @@ async function fetchJson(url, options, timeoutMs) { }); const text = await response.text(); if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -314,7 +314,7 @@ async function generateOne(env, concept, size) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`VectorEngine returned no image for ${concept.id}`); + throw new Error(`Tiantoken returned no image for ${concept.id}`); } mkdirSync(outDir, { recursive: true }); @@ -375,7 +375,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/scripts/make-taonier-hand-spirit-transparent.mjs b/scripts/make-taonier-hand-spirit-transparent.mjs index 58cf53605..b70a9a9d0 100644 --- a/scripts/make-taonier-hand-spirit-transparent.mjs +++ b/scripts/make-taonier-hand-spirit-transparent.mjs @@ -87,12 +87,12 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + baseUrl: String(loaded.TIANTOKEN_BASE_URL || '') .trim() .replace(/\/+$/u, ''), - apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault), + String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault), 10, ), }; @@ -180,12 +180,12 @@ async function fetchJson(url, options, timeoutMs) { }); const text = await response.text(); if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } catch (error) { if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`); } throw error; } finally { @@ -235,7 +235,7 @@ async function generateChromaSource() { throw new Error( JSON.stringify({ ok: false, - error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), @@ -263,7 +263,7 @@ async function generateChromaSource() { } else if (b64Images[0]) { bytes = Buffer.from(b64Images[0], 'base64'); } else { - throw new Error('VectorEngine returned no image'); + throw new Error('Tiantoken returned no image'); } mkdirSync(outputDir, { recursive: true }); diff --git a/scripts/test-ve-llm.mjs b/scripts/test-ve-llm.mjs index ea1b1f76a..e21cec718 100644 --- a/scripts/test-ve-llm.mjs +++ b/scripts/test-ve-llm.mjs @@ -28,12 +28,11 @@ function loadEnv(path) { const env = loadEnv(resolve(root, '.env.secrets.local')); const BASE = - env.VECTOR_ENGINE_BASE_URL?.replace(/\/+$/, '') || - 'https://api.vectorengine.cn'; -const KEY = env.VECTOR_ENGINE_API_KEY || ''; + env.TIANTOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://api.tiantoken.com'; +const KEY = env.TIANTOKEN_API_KEY || ''; if (!KEY) { - console.error('未找到 VECTOR_ENGINE_API_KEY'); + console.error('未找到 TIANTOKEN_API_KEY'); process.exit(1); } @@ -90,7 +89,7 @@ async function test(name, method, path, body = null) { } } -console.log(`VectorEngine LLM 能力探测`); +console.log(`Tiantoken LLM 能力探测`); console.log(`目标: ${BASE}\n`); const tests = [ @@ -185,12 +184,12 @@ console.log( // 结论 if (pass >= 3) { - console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。'); + console.log('\n✅ Tiantoken 支持 LLM 文本调用,可替代 Apimart。'); console.log( - ' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。', + ' 将 .env.secrets.local 中 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配好即可。', ); } else if (pass <= 1) { - console.log('\n❌ VectorEngine 不支持 LLM 文本调用。'); + console.log('\n❌ Tiantoken 不支持 LLM 文本调用。'); } else { console.log('\n⚠️ 部分支持,需进一步评估。'); } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f3b6057f5..94ea4d4e4 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -1175,6 +1175,7 @@ impl AppConfig { read_first_non_empty_env(&[ "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", + "TIANTOKEN_API_KEY", "VECTOR_ENGINE_API_KEY", "ARK_API_KEY", ]) @@ -1183,6 +1184,7 @@ impl AppConfig { "GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY", + "TIANTOKEN_API_KEY", "VECTOR_ENGINE_API_KEY", ]) }; @@ -1275,11 +1277,12 @@ impl AppConfig { config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]); - if let Some(vector_engine_image_request_timeout_ms) = - read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"]) - { + if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[ + "TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS", + "VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", + ]) { // 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。 - config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms; + config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms; } if let Some(vector_engine_audio_request_timeout_ms) = @@ -1425,6 +1428,20 @@ impl AppConfig { } } +/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。 +/// +/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作; +/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。 +pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String { + read_first_non_empty_env(&["TIANTOKEN_BASE_URL"]) + .unwrap_or_else(|| config.vector_engine_base_url.clone()) +} + +pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option { + read_first_non_empty_env(&["TIANTOKEN_API_KEY"]) + .or_else(|| config.vector_engine_api_key.clone()) +} + fn read_first_non_empty_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key).ok().and_then(|value| { @@ -1730,6 +1747,7 @@ mod tests { DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS, DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode, LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, + tiantoken_api_key, tiantoken_base_url, }; use std::{ fs, @@ -1822,6 +1840,40 @@ mod tests { } } + #[test] + fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + let mut config = AppConfig::default(); + config.vector_engine_base_url = "https://vector.example.invalid".to_string(); + config.vector_engine_api_key = Some("legacy-vector-key".to_string()); + unsafe { + std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ "); + std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key "); + } + + assert_eq!( + tiantoken_base_url(&config), + "https://api.tiantoken.example/" + ); + assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key")); + + unsafe { + std::env::remove_var("TIANTOKEN_BASE_URL"); + std::env::remove_var("TIANTOKEN_API_KEY"); + } + assert_eq!( + tiantoken_base_url(&config), + "https://vector.example.invalid" + ); + assert_eq!( + tiantoken_api_key(&config).as_deref(), + Some("legacy-vector-key") + ); + } + #[test] fn llm_router_key_encryption_secret_prefers_dedicated_secret_or_derives_from_jwt() { let mut config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs index 434e8f8a3..45c42d631 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -117,7 +117,7 @@ pub(crate) async fn resolve_editor_screen_background_color( .map(|report| report.allowed.clone()) .unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec()); - // 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量), + // 决策统一走 Tiantoken gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量), // 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认 // llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。 let (llm_client, decision_model) = match vision_llm_client { @@ -860,10 +860,10 @@ mod tests { ); } - // 真机联调:按 build_editor_agent_llm_client 的方式组 VectorEngine 客户端,直接跑 + // 真机联调:按 build_editor_agent_llm_client 的方式组 Tiantoken 客户端,直接跑 // resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档), - // 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。 - // 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。 + // 验证决策请求真的打到 Tiantoken 并被解析成候选色(decision.fallback == false)。 + // 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 TIANTOKEN_* 才有意义。 // 运行:cargo test -p api-server --manifest-path server-rs/Cargo.toml \ // editor_screen_background_decision::tests::live -- --ignored --nocapture fn read_live_env(key: &str) -> Option { @@ -895,11 +895,11 @@ mod tests { std::env::var(key).ok().or_else(|| map.get(key).cloned()) } - fn build_live_vector_engine_client() -> Option { + fn build_live_tiantoken_client() -> Option { use platform_llm::{LlmConfig, LlmProvider}; - let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?; - let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?; + let base_url = read_live_env("TIANTOKEN_BASE_URL")?; + let api_key = read_live_env("TIANTOKEN_API_KEY")?; // 与 state.rs build_editor_agent_llm_client 一致:规整到以 /v1 结尾。 let base_url = if base_url.trim_end_matches('/').ends_with("/v1") { base_url.trim_end_matches('/').to_string() @@ -915,8 +915,8 @@ mod tests { 0, 500, ) - .expect("live VectorEngine LlmConfig should build"); - Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build")) + .expect("live Tiantoken LlmConfig should build"); + Some(LlmClient::new(config).expect("live Tiantoken LlmClient should build")) } fn solid_source_image_data_url() -> String { @@ -938,10 +938,10 @@ mod tests { } #[tokio::test] - #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"] - async fn live_screen_background_decision_hits_vector_engine_without_image() { - let Some(client) = build_live_vector_engine_client() else { - panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调"); + #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"] + async fn live_screen_background_decision_hits_tiantoken_without_image() { + let Some(client) = build_live_tiantoken_client() else { + panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调"); }; let decision = resolve_editor_screen_background_color( None, @@ -970,16 +970,16 @@ mod tests { assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto); assert!( !decision.fallback, - "若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)" + "若走到兜底说明 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)" ); assert!(decision.attempts >= 1); } #[tokio::test] - #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"] - async fn live_screen_background_decision_hits_vector_engine_with_image() { - let Some(client) = build_live_vector_engine_client() else { - panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调"); + #[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"] + async fn live_screen_background_decision_hits_tiantoken_with_image() { + let Some(client) = build_live_tiantoken_client() else { + panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调"); }; let decision = resolve_editor_screen_background_color( None, @@ -1008,7 +1008,7 @@ mod tests { assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto); assert!( !decision.fallback, - "若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)" + "若走到兜底说明视觉 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)" ); assert!(decision.attempts >= 1); } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 722213247..b279b5054 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1905,8 +1905,8 @@ mod tests { "code": "UPSTREAM_ERROR", "message": "上游服务请求失败", "details": { - "provider": "vector-engine", - "reason": "VECTOR_ENGINE_API_KEY 未配置", + "provider": "tiantoken", + "reason": "TIANTOKEN_API_KEY 未配置", "message": "提交编辑器音效任务失败:missing field sound" } }, @@ -1918,7 +1918,7 @@ mod tests { let message = response_error_message(response).await; - assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置"); + assert_eq!(message, "TIANTOKEN_API_KEY 未配置"); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index f0fa7591c..2cf90c425 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -75,34 +75,29 @@ impl std::fmt::Debug for OpenAiImageSettings { } } -// 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。 +// 中文注释:api-server 只负责配置、审计和 HTTP envelope,Tiantoken 的 OpenAI-compatible +// 图片协议细节统一由 platform-image provider 承接。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); + let base_url = state.tiantoken_base_url().trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "reason": "VECTOR_ENGINE_BASE_URL 未配置", + "provider": "tiantoken", + "reason": "TIANTOKEN_BASE_URL 未配置", })), ); } let api_key = state - .config - .vector_engine_api_key - .as_deref() + .tiantoken_api_key() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "reason": "VECTOR_ENGINE_API_KEY 未配置", + "provider": "tiantoken", + "reason": "TIANTOKEN_API_KEY 未配置", })) })?; diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index dc7b08503..b4030cd62 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -303,6 +303,9 @@ pub struct AppStateInner { editor_generation_pricing_store: EditorGenerationPricingStore, llm_client: Option, vector_engine_llm_client: Option, + /// 非 Suno 的文本、图片和旧版音频生成 provider 配置。 + tiantoken_base_url: String, + tiantoken_api_key: Option, matting_client: Option, bgfilter_provider_http_client: reqwest::Client, bgfilter_worker_http_client: reqwest::Client, @@ -600,8 +603,14 @@ impl AppState { config.editor_generation_pricing_override_path.clone(), ) .map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?; + let tiantoken_base_url = crate::config::tiantoken_base_url(&config); + let tiantoken_api_key = crate::config::tiantoken_api_key(&config); let llm_client = build_llm_client(&config)?; - let vector_engine_llm_client = build_vector_engine_llm_client(&config)?; + let vector_engine_llm_client = build_vector_engine_llm_client( + &config, + &tiantoken_base_url, + tiantoken_api_key.as_deref(), + )?; let matting_client = build_matting_client(&config)?; let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?; let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?; @@ -677,6 +686,8 @@ impl AppState { editor_generation_pricing_store, llm_client, vector_engine_llm_client, + tiantoken_base_url, + tiantoken_api_key, matting_client, bgfilter_provider_http_client, bgfilter_worker_http_client, @@ -1579,6 +1590,14 @@ impl AppState { self.vector_engine_llm_client.as_ref() } + pub fn tiantoken_base_url(&self) -> &str { + self.tiantoken_base_url.as_str() + } + + pub fn tiantoken_api_key(&self) -> Option<&str> { + self.tiantoken_api_key.as_deref() + } + pub fn matting_client(&self) -> Option<&MattingClient> { self.matting_client.as_ref() } @@ -2490,21 +2509,21 @@ fn build_llm_client(config: &AppConfig) -> Result, AppStateIni fn build_vector_engine_llm_client( config: &AppConfig, + tiantoken_base_url: &str, + tiantoken_api_key: Option<&str>, ) -> Result, AppStateInitError> { - // 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine。 - let Some(api_key) = config - .vector_engine_api_key - .as_ref() - .map(|value| value.trim()) + // 中文注释:Apimart 已于 2026-06 弃用,非 Suno 文本调用统一迁移到 Tiantoken。 + let Some(api_key) = tiantoken_api_key + .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(None); }; - let base_url = if config.vector_engine_base_url.ends_with("/v1") { - config.vector_engine_base_url.clone() + let base_url = if tiantoken_base_url.trim_end_matches('/').ends_with("/v1") { + tiantoken_base_url.trim_end_matches('/').to_string() } else { - format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/')) + format!("{}/v1", tiantoken_base_url.trim_end_matches('/')) }; let llm_config = LlmConfig::new( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs deleted file mode 100644 index 5d3ee628b..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/handlers.rs +++ /dev/null @@ -1,215 +0,0 @@ -use axum::{ - Json, - extract::{Path, State, rejection::JsonRejection}, - response::Response, -}; -use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest}; -use serde_json::Value; -use shared_contracts::{creation_audio, visual_novel as contract}; - -use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, - request_context::RequestContext, state::AppState, -}; - -use super::{ - errors::{map_platform_audio_error, parse_json_payload}, - generation::normalize_creation_sound_effect_duration, - publish::publish_generated_audio_asset, - settings::require_vector_engine_audio_settings, - targets::{ - build_creation_audio_target, build_visual_novel_audio_target, - creation_audio_generation_disabled_error, - creation_audio_generation_disabled_error_for_target, - }, - types::AudioAssetSlot, -}; - -pub async fn create_visual_novel_background_music_task( - State(state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_background_music_task( - &http_client, - &settings, - BackgroundMusicTaskRequest { - prompt: payload.prompt, - title: payload.title, - tags: payload.tags, - model: payload.model, - instrumental: true, - }, - ) - .await - .map_err(map_platform_audio_error)?; - - Ok(json_success_body( - Some(&request_context), - contract::VisualNovelAudioGenerationTaskResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id: task.task_id, - provider: task.provider, - status: task.status, - }, - )) -} - -pub async fn create_background_music_task( - State(_state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub async fn create_visual_novel_sound_effect_task( - State(state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = parse_json_payload(&request_context, payload)?; - let settings = require_vector_engine_audio_settings(&state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_sound_effect_task( - &http_client, - &settings, - SoundEffectTaskRequest { - prompt: payload.prompt, - duration: normalize_creation_sound_effect_duration(payload.duration) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?, - seed: payload.seed, - }, - ) - .await - .map_err(map_platform_audio_error)?; - - Ok(json_success_body( - Some(&request_context), - contract::VisualNovelAudioGenerationTaskResponse { - kind: contract::VisualNovelAudioGenerationKind::SoundEffect, - task_id: task.task_id, - provider: task.provider, - status: task.status, - }, - )) -} - -pub async fn create_sound_effect_task( - State(_state): State, - axum::extract::Extension(request_context): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = parse_json_payload(&request_context, payload)?; - Err(creation_audio_generation_disabled_error() - .into_response_with_context(Some(&request_context))) -} - -pub async fn publish_visual_novel_background_music_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::BackgroundMusic, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_visual_novel_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| { - json_success_body( - Some(&request_context), - contract::VisualNovelGeneratedAudioAssetResponse { - kind: contract::VisualNovelAudioGenerationKind::SoundEffect, - task_id: payload.task_id, - provider: payload.provider, - status: payload.status, - asset_object_id: payload.asset_object_id, - asset_kind: payload.asset_kind, - audio_src: payload.audio_src, - }, - ) - }) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} - -pub async fn publish_background_music_asset( - State(_state): State, - Path(_task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(_authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - Err(creation_audio_generation_disabled_error_for_target(payload) - .into_response_with_context(Some(&request_context))) -} - -pub async fn publish_sound_effect_asset( - State(state): State, - Path(task_id): Path, - axum::extract::Extension(request_context): axum::extract::Extension, - axum::extract::Extension(authenticated): axum::extract::Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = parse_json_payload(&request_context, payload)?.0; - let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - publish_generated_audio_asset( - &state, - authenticated.claims().user_id(), - task_id, - AudioAssetSlot::SoundEffect, - target, - ) - .await - .map(|payload| json_success_body(Some(&request_context), payload)) - .map_err(|error| error.into_response_with_context(Some(&request_context))) -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs index 5b50e846c..bcd1d7974 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/publish.rs @@ -11,10 +11,7 @@ use super::{ errors::{map_platform_audio_error, vector_engine_bad_gateway}, persist::persist_generated_audio_asset, settings::require_vector_engine_audio_settings, - types::{ - AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST, - CREATION_SOUND_EFFECT_POINTS_COST, - }, + types::{AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST}, }; #[cfg(any())] @@ -208,6 +205,5 @@ pub(super) fn resolve_creation_audio_points_cost( } match slot { AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST, - AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST, } } diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs deleted file mode 100644 index 8a17b5466..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs +++ /dev/null @@ -1,38 +0,0 @@ -use platform_audio::SoundEffectTaskRequest; -use shared_contracts::creation_audio; - -use crate::{http_error::AppError, state::AppState}; - -use super::{ - errors::map_platform_audio_error, generation::normalize_creation_sound_effect_duration, - settings::require_vector_engine_audio_settings, -}; - -pub(super) async fn create_sound_effect_task_response( - state: &AppState, - prompt: String, - duration: Option, - seed: Option, -) -> Result { - let settings = require_vector_engine_audio_settings(state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_sound_effect_task( - &http_client, - &settings, - SoundEffectTaskRequest { - prompt, - duration: normalize_creation_sound_effect_duration(duration)?, - seed, - }, - ) - .await - .map_err(map_platform_audio_error)?; - - Ok(creation_audio::AudioGenerationTaskResponse { - kind: creation_audio::CreationAudioGenerationKind::SoundEffect, - task_id: task.task_id, - provider: task.provider, - status: task.status, - }) -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs deleted file mode 100644 index 397e9b373..000000000 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tests.rs +++ /dev/null @@ -1,212 +0,0 @@ -use axum::http::StatusCode; -use platform_oss::LegacyAssetPrefix; -use shared_contracts::{assets, creation_audio}; - -use super::{ - generation::{ - normalize_creation_sound_effect_duration, normalize_editor_background_music_request, - normalize_editor_sound_effect_request, - }, - publish::resolve_creation_audio_points_cost, - targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target}, - types::{AudioAssetBindingTarget, AudioAssetSlot}, -}; - -#[test] -fn creation_audio_billing_uses_lower_cost_for_background_music() { - let target = AudioAssetBindingTarget { - entity_kind: "puzzle_work".to_string(), - entity_id: "puzzle-profile-1".to_string(), - slot: "background_music".to_string(), - asset_kind: "puzzle_background_music".to_string(), - profile_id: Some("puzzle-profile-1".to_string()), - storage_prefix: LegacyAssetPrefix::PuzzleAssets, - storage_scope: "puzzle_work".to_string(), - billing_points_cost: None, - }; - - assert_eq!( - resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target), - 5 - ); - assert_eq!( - resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target), - 10 - ); -} - -#[test] -fn editor_audio_billing_uses_model_price_from_generation_request() { - let target = AudioAssetBindingTarget { - entity_kind: "editor_audio".to_string(), - entity_id: "task-editor-audio".to_string(), - slot: "background_music".to_string(), - asset_kind: "editor_background_music".to_string(), - profile_id: None, - storage_prefix: LegacyAssetPrefix::CharacterDrafts, - storage_scope: "editor_audio".to_string(), - billing_points_cost: Some(12), - }; - - assert_eq!( - resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target), - 12 - ); -} - -#[test] -fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() { - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "puzzle_work".to_string(), - entity_id: "puzzle-profile-1".to_string(), - slot: "background_music".to_string(), - asset_kind: "puzzle_background_music".to_string(), - profile_id: Some("puzzle-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets), - }; - let error = creation_audio_generation_disabled_error_for_target(payload); - assert_eq!(error.status_code(), StatusCode::GONE); - - let payload = creation_audio::PublishGeneratedAudioAssetRequest { - entity_kind: "wooden_fish_work".to_string(), - entity_id: "wooden-fish-profile-1".to_string(), - slot: "hit_sound".to_string(), - asset_kind: "wooden_fish_hit_sound".to_string(), - profile_id: Some("wooden-fish-profile-1".to_string()), - storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets), - }; - let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect) - .expect_err("wooden fish hit sound target should be disabled"); - assert_eq!(error.status_code(), StatusCode::GONE); -} - -#[test] -fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() { - let normalized = - normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest { - prompt: " 金币掉落叮当声 ".to_string(), - model: None, - duration: 7, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect("editor sound effect request should normalize"); - - assert_eq!(normalized.prompt, "金币掉落叮当声"); - assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL); - assert_eq!(normalized.duration, 7); - assert_eq!(normalized.price_mud_points, 5); -} - -#[test] -fn editor_sound_effect_request_accepts_only_vidu_audio_model() { - let normalized = - normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest { - prompt: "金币掉落叮当声".to_string(), - model: Some(" audio1.0 ".to_string()), - duration: 5, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect("Vidu audio model should be accepted"); - - assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL); - - let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest { - prompt: "金币掉落叮当声".to_string(), - model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()), - duration: 5, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect_err("Suno text-to-sound should be disabled for editor sound effects"); - - assert!(error.to_string().contains("Vidu")); -} - -#[test] -fn editor_sound_effect_request_rejects_duration_outside_vidu_range() { - for duration in [1, 11] { - let error = - normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest { - prompt: "按钮点击".to_string(), - model: None, - duration, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect_err("duration outside 2-10 seconds should fail"); - - assert!(error.to_string().contains("2-10")); - } -} - -#[test] -fn creation_sound_effect_duration_rejects_outside_vidu_range() { - assert_eq!( - normalize_creation_sound_effect_duration(None).expect("default duration should pass"), - platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS - ); - assert_eq!( - normalize_creation_sound_effect_duration(Some(2)).expect("2 seconds should pass"), - 2 - ); - assert_eq!( - normalize_creation_sound_effect_duration(Some(10)).expect("10 seconds should pass"), - 10 - ); - - for duration in [Some(1), Some(11)] { - let error = normalize_creation_sound_effect_duration(duration) - .expect_err("duration outside Vidu 2-10 seconds should fail"); - assert!(error.to_string().contains("2-10")); - } -} - -#[test] -fn editor_background_music_request_forces_instrumental_and_resolves_price() { - let normalized = - normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest { - gpt_description_prompt: " 森林冒险背景音乐 ".to_string(), - make_instrumental: false, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect("editor background music request should normalize"); - - assert_eq!(normalized.gpt_description_prompt, "森林冒险背景音乐"); - assert!(normalized.make_instrumental); - assert_eq!(normalized.price_mud_points, 12); -} - -#[test] -fn editor_background_music_request_rejects_prompt_over_documented_limit() { - let error = - normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest { - gpt_description_prompt: "乐".repeat(201), - make_instrumental: true, - project_id: None, - canvas_completion: None, - generation_inputs: None, - asset_folder_id: None, - asset_label: None, - }) - .expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit"); - - assert!(error.to_string().contains("gpt_description_prompt")); -} diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs index 515962028..7af8eedbc 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/types.rs @@ -14,7 +14,6 @@ pub(super) const MUSIC_SLOT: &str = "music"; #[cfg(any())] pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound"; pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5; -pub(super) const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10; #[derive(Clone, Debug)] pub(super) struct AudioAssetBindingTarget { @@ -31,14 +30,12 @@ pub(super) struct AudioAssetBindingTarget { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum AudioAssetSlot { BackgroundMusic, - SoundEffect, } impl AudioAssetSlot { pub(super) fn task_kind(self) -> AudioTaskKind { match self { Self::BackgroundMusic => AudioTaskKind::BackgroundMusic, - Self::SoundEffect => AudioTaskKind::SoundEffect, } } @@ -65,7 +62,6 @@ impl AudioAssetSlot { pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind { match self { Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic, - Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect, } } } diff --git a/server-rs/crates/platform-audio/src/client.rs b/server-rs/crates/platform-audio/src/client.rs index df8205746..21fce0f3e 100644 --- a/server-rs/crates/platform-audio/src/client.rs +++ b/server-rs/crates/platform-audio/src/client.rs @@ -9,10 +9,8 @@ use crate::response::{ }; use crate::{ AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SoundEffectTaskRequest, - VectorEngineAudioSettings, build_background_music_task_body, - build_editor_background_music_task_body, build_editor_sound_effect_task_body, - build_sound_effect_task_body, + EditorBackgroundMusicTaskRequest, VectorEngineAudioSettings, build_background_music_task_body, + build_editor_background_music_task_body, }; pub fn build_vector_engine_audio_http_client( @@ -56,32 +54,6 @@ pub async fn submit_background_music_task( }) } -pub async fn submit_sound_effect_task( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - request: SoundEffectTaskRequest, -) -> Result { - let body = build_sound_effect_task_body(request)?; - let response = post_vector_engine_json( - http_client, - settings, - AudioTaskKind::SoundEffect.submit_path(), - body, - "提交 Vidu 音效任务失败", - ) - .await?; - let task_id = extract_submit_task_id(&response) - .ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(AudioTaskResponse { - kind: AudioTaskKind::SoundEffect, - task_id, - provider: AudioTaskKind::SoundEffect.provider().to_string(), - status, - }) -} - pub async fn submit_editor_background_music_task( http_client: &reqwest::Client, settings: &VectorEngineAudioSettings, @@ -108,32 +80,6 @@ pub async fn submit_editor_background_music_task( }) } -pub async fn submit_editor_sound_effect_task( - http_client: &reqwest::Client, - settings: &VectorEngineAudioSettings, - request: EditorSoundEffectTaskRequest, -) -> Result { - let body = build_editor_sound_effect_task_body(request)?; - let response = post_vector_engine_json( - http_client, - settings, - AudioTaskKind::SoundEffect.submit_path(), - body, - "提交编辑器音效任务失败", - ) - .await?; - let task_id = extract_submit_task_id(&response) - .ok_or_else(|| AudioError::missing_audio("提交编辑器音效任务失败:上游未返回任务 ID"))?; - let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into()); - - Ok(AudioTaskResponse { - kind: AudioTaskKind::SoundEffect, - task_id, - provider: AudioTaskKind::SoundEffect.provider().to_string(), - status, - }) -} - async fn fetch_audio_task_payload( http_client: &reqwest::Client, settings: &VectorEngineAudioSettings, @@ -146,7 +92,6 @@ async fn fetch_audio_task_payload( &kind.fetch_path(task_id), match kind { AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败", - AudioTaskKind::SoundEffect => "查询 Vidu 音效任务失败", AudioTaskKind::SunoSoundEffect => "查询 Suno 音效任务失败", }, ) diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index 08a1beac5..5af8d9fbc 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -18,7 +18,6 @@ pub use background_music_prompt::{ pub use client::{ build_vector_engine_audio_http_client, resolve_audio_task_download_urls, submit_background_music_task, submit_editor_background_music_task, - submit_editor_sound_effect_task, submit_sound_effect_task, }; pub use download::{audio_mime_to_extension, download_generated_audio, normalize_audio_mime_type}; pub use elevenlabs::{ @@ -36,8 +35,7 @@ pub use persist::{ }; pub use request::{ build_background_music_task_body, build_editor_background_music_task_body, - build_editor_sound_effect_task_body, build_sound_effect_task_body, normalize_limited_text, - normalize_limited_text_allow_empty, normalize_optional_text, + normalize_limited_text, normalize_limited_text_allow_empty, normalize_optional_text, }; pub use response::{ extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status, @@ -52,9 +50,8 @@ pub use sound_effect_prompt::{ pub use types::{ AudioTaskKind, AudioTaskResponse, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, MAX_GENERATED_AUDIO_BYTES, - SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, - SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER, - VECTOR_ENGINE_SUNO_PROVIDER, VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL, - VIDU_PROMPT_MAX_CHARS, VectorEngineAudioSettings, + EditorBackgroundMusicTaskRequest, MAX_GENERATED_AUDIO_BYTES, SUNO_DEFAULT_MODEL, + SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, + SUNO_TITLE_MAX_CHARS, VECTOR_ENGINE_PROVIDER, VECTOR_ENGINE_SUNO_PROVIDER, + VectorEngineAudioSettings, }; diff --git a/server-rs/crates/platform-audio/src/persist.rs b/server-rs/crates/platform-audio/src/persist.rs index 79e3a3474..9de275520 100644 --- a/server-rs/crates/platform-audio/src/persist.rs +++ b/server-rs/crates/platform-audio/src/persist.rs @@ -163,12 +163,5 @@ mod tests { file_stem: "background-music".to_string(), } ); - assert_eq!( - GeneratedAudioPersistSource::from_task_kind(AudioTaskKind::SoundEffect), - GeneratedAudioPersistSource { - provider: crate::VECTOR_ENGINE_VIDU_PROVIDER.to_string(), - file_stem: "sound-effect".to_string(), - } - ); } } diff --git a/server-rs/crates/platform-audio/src/request.rs b/server-rs/crates/platform-audio/src/request.rs index 272aac385..802062cdf 100644 --- a/server-rs/crates/platform-audio/src/request.rs +++ b/server-rs/crates/platform-audio/src/request.rs @@ -1,9 +1,8 @@ use serde_json::{Map, Value, json}; use crate::{ - AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, - EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, - SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + AudioError, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, + SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, }; pub fn build_background_music_task_body( @@ -36,28 +35,6 @@ pub fn build_background_music_task_body( Ok(Value::Object(body)) } -pub fn build_sound_effect_task_body(request: SoundEffectTaskRequest) -> Result { - let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - if !(2..=10).contains(&request.duration) { - return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间")); - } - let mut body = Map::from_iter([ - ( - "model".to_string(), - Value::String(VIDU_AUDIO_MODEL.to_string()), - ), - ("prompt".to_string(), Value::String(prompt.clone())), - // 中文注释:VectorEngine Apifox 当前仍写 prompt,但线上 Vidu 网关曾返回 - // missing field sound;同时发送 sound 作为同义字段,兼容网关实际反序列化。 - ("sound".to_string(), Value::String(prompt)), - ("duration".to_string(), json!(request.duration)), - ]); - if let Some(seed) = request.seed { - body.insert("seed".to_string(), json!(seed)); - } - Ok(Value::Object(body)) -} - pub fn build_editor_background_music_task_body( request: EditorBackgroundMusicTaskRequest, ) -> Result { @@ -72,33 +49,6 @@ pub fn build_editor_background_music_task_body( })) } -pub fn build_editor_sound_effect_task_body( - request: EditorSoundEffectTaskRequest, -) -> Result { - let prompt = normalize_limited_text(&request.prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?; - if !(2..=10).contains(&request.duration) { - return Err(AudioError::invalid_request("duration 必须在 2-10 秒之间")); - } - let model = normalize_optional_text(request.model.as_deref()) - .unwrap_or_else(|| VIDU_AUDIO_MODEL.to_string()); - if model != VIDU_AUDIO_MODEL { - return Err(AudioError::invalid_request( - "编辑器音效暂只支持 Vidu audio1.0 模型", - )); - } - let mut body = Map::from_iter([ - ("model".to_string(), Value::String(model)), - ("prompt".to_string(), Value::String(prompt.clone())), - // 中文注释:同上,编辑器音效也必须带 sound,避免上游按 sound 字段解析时报缺字段。 - ("sound".to_string(), Value::String(prompt)), - ("duration".to_string(), json!(request.duration)), - ]); - if let Some(seed) = request.seed { - body.insert("seed".to_string(), json!(seed)); - } - Ok(Value::Object(body)) -} - pub fn normalize_limited_text( value: &str, field: &'static str, diff --git a/server-rs/crates/platform-audio/src/types.rs b/server-rs/crates/platform-audio/src/types.rs index 9a7befe54..65b647d9b 100644 --- a/server-rs/crates/platform-audio/src/types.rs +++ b/server-rs/crates/platform-audio/src/types.rs @@ -1,7 +1,6 @@ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AudioTaskKind { BackgroundMusic, - SoundEffect, SunoSoundEffect, } @@ -9,7 +8,6 @@ impl AudioTaskKind { pub fn provider(self) -> &'static str { match self { Self::BackgroundMusic => VECTOR_ENGINE_SUNO_PROVIDER, - Self::SoundEffect => VECTOR_ENGINE_VIDU_PROVIDER, Self::SunoSoundEffect => VECTOR_ENGINE_SUNO_PROVIDER, } } @@ -17,7 +15,6 @@ impl AudioTaskKind { pub fn submit_path(self) -> &'static str { match self { Self::BackgroundMusic => "/suno/submit/music", - Self::SoundEffect => "/ent/v2/text2audio", Self::SunoSoundEffect => "/suno/submit/music", } } @@ -25,9 +22,6 @@ impl AudioTaskKind { pub fn fetch_path(self, task_id: &str) -> String { match self { Self::BackgroundMusic => format!("/suno/fetch/{}", urlencoding::encode(task_id)), - Self::SoundEffect => { - format!("/ent/v2/tasks/{}/creations", urlencoding::encode(task_id)) - } Self::SunoSoundEffect => format!("/suno/fetch/{}", urlencoding::encode(task_id)), } } @@ -35,7 +29,6 @@ impl AudioTaskKind { pub fn file_stem(self) -> &'static str { match self { Self::BackgroundMusic => "background-music", - Self::SoundEffect => "sound-effect", Self::SunoSoundEffect => "sound-effect", } } @@ -50,13 +43,6 @@ pub struct BackgroundMusicTaskRequest { pub instrumental: bool, } -#[derive(Clone, Debug)] -pub struct SoundEffectTaskRequest { - pub prompt: String, - pub duration: u8, - pub seed: Option, -} - #[derive(Clone, Debug)] pub struct EditorBackgroundMusicTaskRequest { pub gpt_description_prompt: String, @@ -64,14 +50,6 @@ pub struct EditorBackgroundMusicTaskRequest { pub model: Option, } -#[derive(Clone, Debug)] -pub struct EditorSoundEffectTaskRequest { - pub prompt: String, - pub duration: u8, - pub seed: Option, - pub model: Option, -} - #[derive(Clone, Debug)] pub struct AudioTaskResponse { pub kind: AudioTaskKind, @@ -96,15 +74,12 @@ pub struct DownloadedAudio { pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; pub const VECTOR_ENGINE_SUNO_PROVIDER: &str = "vector-engine-suno"; -pub const VECTOR_ENGINE_VIDU_PROVIDER: &str = "vector-engine-vidu"; pub const SUNO_DEFAULT_MODEL: &str = "chirp-v5"; -pub const VIDU_AUDIO_MODEL: &str = "audio1.0"; pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000; pub const SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS: usize = 200; pub const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS: usize = SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS * 10; pub const SUNO_TITLE_MAX_CHARS: usize = 80; pub const SUNO_TAGS_MAX_CHARS: usize = 160; -pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500; pub const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5; pub const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024; diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index d966359b9..edf5b5054 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -1,12 +1,10 @@ use platform_audio::{ AudioTaskKind, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest, - EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, - SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS, + EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, audio_mime_to_extension, background_music_prompt_char_count, background_music_prompt_effective_char_count, build_background_music_task_body, - build_editor_background_music_task_body, build_editor_sound_effect_task_body, - build_sound_effect_task_body, canonicalize_background_music_prompt, extract_audio_urls, - is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, + build_editor_background_music_task_body, canonicalize_background_music_prompt, + extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_audio_mime_type, normalize_task_status, validate_background_music_completion_prompt, validate_background_music_generation_prompt, }; @@ -271,19 +269,6 @@ fn background_music_request_body_uses_default_model_and_optional_instrumental_fl ); } -#[test] -fn sound_effect_request_rejects_overlong_prompt() { - let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1); - let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt, - duration: 5, - seed: None, - }) - .expect_err("long prompt should fail"); - - assert!(error.message().contains("prompt 超过")); -} - #[test] fn editor_background_music_request_body_uses_gpt_description_prompt_and_instrumental() { let fixture = background_music_prompt_canonicalization_fixture(); @@ -357,85 +342,3 @@ fn editor_background_music_request_body_rejects_unicode_whitespace_only_prompt() assert!(error.message().contains("至少需要 1 个有效字符")); } - -#[test] -fn vidu_sound_effect_request_body_uses_text2audio_contract() { - let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: " 金币掉落叮当声 ".to_string(), - duration: 5, - seed: None, - }) - .expect("Vidu sound effect body should be valid"); - - assert_eq!( - AudioTaskKind::SoundEffect.submit_path(), - "/ent/v2/text2audio" - ); - assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu"); - assert_eq!(body["model"], VIDU_AUDIO_MODEL); - assert_eq!(body["prompt"], "金币掉落叮当声"); - assert_eq!(body["sound"], "金币掉落叮当声"); - assert_eq!(body["duration"], 5); - assert!(body.get("mv").is_none()); - assert!(body.get("task").is_none()); - assert!(body.get("metadata_params").is_none()); -} - -#[test] -fn vidu_sound_effect_request_body_rejects_duration_outside_vidu_range_and_keeps_seed() { - let body = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: "按钮确认短促音".to_string(), - duration: 2, - seed: Some(42), - }) - .expect("Vidu 2 second duration should be accepted"); - assert_eq!(body["duration"], 2); - assert_eq!(body["seed"], 42); - - for duration in [1, 11] { - let error = build_sound_effect_task_body(platform_audio::SoundEffectTaskRequest { - prompt: "循环环境音".to_string(), - duration, - seed: None, - }) - .expect_err("duration outside Vidu 2-10 seconds should fail"); - assert!(error.message().contains("duration")); - } -} - -#[test] -fn editor_sound_effect_request_body_uses_vidu_text2audio_contract() { - let body = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest { - prompt: " 金币掉落叮当声 ".to_string(), - duration: 7, - seed: Some(42), - model: Some(VIDU_AUDIO_MODEL.to_string()), - }) - .expect("editor sound effect should use Vidu body"); - - assert_eq!(body["model"], VIDU_AUDIO_MODEL); - assert_eq!(body["prompt"], "金币掉落叮当声"); - assert_eq!(body["sound"], "金币掉落叮当声"); - assert_eq!(body["duration"], 7); - assert_eq!(body["seed"], 42); - assert!(body.get("mv").is_none()); - assert!(body.get("task").is_none()); - assert!(body.get("metadata_params").is_none()); - assert!(body.get("type").is_none()); - assert!(body.get("tempo").is_none()); -} - -#[test] -fn editor_sound_effect_request_body_rejects_duration_outside_vidu_range() { - for duration in [1, 11] { - let error = build_editor_sound_effect_task_body(EditorSoundEffectTaskRequest { - prompt: "按钮确认短促音".to_string(), - duration, - seed: None, - model: None, - }) - .expect_err("duration outside Vidu 2-10 seconds should fail"); - - assert!(error.message().contains("duration")); - } -} diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index f4149b26e..b04406338 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1069,12 +1069,12 @@ describe('apiClient', () => { code: 'UPSTREAM_ERROR', message: '上游暂不可用', details: { - provider: 'vector-engine', + provider: 'tiantoken', message: - '创建拼图 VectorEngine 图片编辑任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/edits)', + '创建拼图 Tiantoken 图片编辑任务失败:error sending request for url (https://api.tiantoken.com/v1/images/edits)', reason: - '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', - endpoint: 'https://api.vectorengine.ai/v1/images/edits', + '无法连接 Tiantoken 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', + endpoint: 'https://api.tiantoken.com/v1/images/edits', }, }, meta: {}, @@ -1095,11 +1095,11 @@ describe('apiClient', () => { ), ).rejects.toMatchObject({ message: - '无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', + '无法连接 Tiantoken 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置', status: 502, code: 'UPSTREAM_ERROR', details: { - provider: 'vector-engine', + provider: 'tiantoken', }, }); }); @@ -1116,8 +1116,8 @@ describe('apiClient', () => { code: 'SERVICE_UNAVAILABLE', message: '服务暂不可用', details: { - provider: 'vector-engine', - reason: 'VECTOR_ENGINE_API_KEY 未配置', + provider: 'tiantoken', + reason: 'TIANTOKEN_API_KEY 未配置', }, }, meta: {}, @@ -1137,11 +1137,11 @@ describe('apiClient', () => { '执行抓大鹅共创操作失败', ), ).rejects.toMatchObject({ - message: 'VECTOR_ENGINE_API_KEY 未配置', + message: 'TIANTOKEN_API_KEY 未配置', status: 503, code: 'SERVICE_UNAVAILABLE', details: { - provider: 'vector-engine', + provider: 'tiantoken', }, }); }); From dd24690b02a70e6df16a620a4f4e7bdd0eb82bbd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:06:36 +0800 Subject: [PATCH 04/49] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E8=BF=9E=E9=80=9A=E5=9F=9F=E4=B8=8E=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=88=87=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 connected-components 与 grid 切分模式 支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端 移除固定 2x2 图集切分契约与文档 --- .../genarrative-external-editor-api/SKILL.md | 1 + .../references/api-operations.md | 4 +- .../references/capability-routing.md | 4 +- .../scripts/genarrative_external_api.py | 2 + .../agc-skills/taonier-art-assets/SKILL.md | 6 + .../references/platform-art-contract.md | 1 + .../src-tauri/src/agent/direct_codex_audit.rs | 1 + .../src-tauri/src/agent/direct_runtime.rs | 3 + .../src-tauri/src/agent/direct_tool_bridge.rs | 44 ++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 26 +++ .../src/agent/generation/canvas_generation.rs | 67 ++++-- .../src/agent/runtime_tools/media.rs | 41 ++++ .../src-tauri/src/commands.rs | 3 + .../src-tauri/src/tests/mod.rs | 4 +- .../src-tauri/src/tests/project.rs | 6 + .../genarrative-external-v1.openapi.json | 47 ++++- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../api-server/src/editor_agent/tool.rs | 4 +- .../crates/api-server/src/editor_project.rs | 42 ++-- .../api-server/src/editor_project_icon.rs | 193 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 8 + .../src/external_generation_worker.rs | 16 +- .../crates/api-server/src/external_mcp.rs | 2 +- .../crates/api-server/src/prompt/icon_spec.rs | 8 +- .../src/generated_asset_sheets/mod.rs | 2 +- .../src/generated_asset_sheets/sheet.rs | 31 +-- .../image-editor/editorProjectClient.test.ts | 5 +- .../image-editor/editorProjectClient.ts | 9 + 28 files changed, 475 insertions(+), 106 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index e6a9f55e7..e4bbafbc2 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,6 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. +- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output. - For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs. - Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 909174e47..a744d8589 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -52,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | | Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | -| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. -`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets. +`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result. ## Common Values diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index a5b4fe886..af0ee338a 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default). -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index cc5a4a4b5..c7c0ab33b 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -881,12 +881,14 @@ def _self_test() -> None: ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], canvasSession=session, assetLabel="贪吃蛇透明图集", + sliceMode="connected-components", referenceId="must-not-override-explicit-reference", iconDescriptions=["不得覆盖显式图标描述"], ) assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" assert calls[0]["body"]["referenceId"] == "editor-resource-spec" assert calls[0]["body"]["screenColor"] == "auto" + assert calls[0]["body"]["sliceMode"] == "connected-components" assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" print("self-test ok") diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index 8d63beb92..d652b4436 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -19,6 +19,12 @@ image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for the complete game-art package and its canonical slices. +When `agc_generate_image` is used with `kind="art-spritesheet"`, pass +`sliceMode="connected-components"` (the default alpha-connectivity splitter) +or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried +through the client request and returned result; do not infer it from the number +of slices. + ## Authorization boundary `agc_tools` is an AGC client-owned bridge to the AGC backend. In the normal client build it uses the current client login session and account routes; the user and model never need to provide, configure, paste, create, or rotate an API Key, Token, Cookie, URL, or `.env` value. If the tool returns `401` or `403`, report only that the AGC client login or permission state is unavailable, stop the operation, and do not ask the user for credentials or expose an internal URL. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md index 9a04e1256..bf0b74481 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -15,6 +15,7 @@ - On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request. - `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent. - `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent. +- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata. - General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim. - `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities. - `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys. diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs index 43ab7c7ba..6f8557a32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -549,6 +549,7 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { } "agc_generate_image" => { copy_string(object, "kind", &mut out); + copy_string(object, "sliceMode", &mut out); copy_string(object, "aspectRatio", &mut out); copy_string(object, "imageSize", &mut out); copy_string(object, "assetName", &mut out); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 2e4cdad61..71cf43b44 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2783,6 +2783,9 @@ async fn generate_direct_taonier_art_asset_at( asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index e185c8cac..6c94bfe6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2104,6 +2104,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) "imageSize", "assetName", "outputPath", + "sliceMode", + "gridX", + "gridY", ], )?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; @@ -2145,6 +2148,44 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) .transpose()? .unwrap_or_else(|| "AI 生成图片".to_string()); let output_path = bridge_optional_bounded_string(arguments, "outputPath", 512)?; + let slice_mode = arguments + .get("sliceMode") + .map(|_| bridge_bounded_string(arguments, "sliceMode", 32)) + .transpose()?; + if slice_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "connected-components" | "grid")) + { + return Err("工具参数 sliceMode 只允许 connected-components 或 grid".to_string()); + } + let grid_x = arguments + .get("gridX") + .map(|_| { + arguments + .get("gridX") + .and_then(Value::as_u64) + .map(|value| value as u32) + .ok_or_else(|| "工具参数 gridX 必须是整数".to_string()) + }) + .transpose()?; + let grid_y = arguments + .get("gridY") + .map(|_| { + arguments + .get("gridY") + .and_then(Value::as_u64) + .map(|value| value as u32) + .ok_or_else(|| "工具参数 gridY 必须是整数".to_string()) + }) + .transpose()?; + if slice_mode.as_deref() == Some("grid") && (grid_x.is_none() || grid_y.is_none()) { + return Err("grid 模式必须同时提供 gridX 与 gridY".to_string()); + } + if grid_x.is_some_and(|value| !(1..=32).contains(&value)) + || grid_y.is_some_and(|value| !(1..=32).contains(&value)) + { + return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string()); + } let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, @@ -2153,6 +2194,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_label: asset_name.clone(), replace_existing: false, slice_count: None, + slice_mode, + grid_x, + grid_y, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 643abd3db..294b44c25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -244,6 +244,24 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "maxLength": 512, "description": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件" + }, + "sliceMode": { + "type": "string", + "enum": ["connected-components", "grid"], + "default": "connected-components", + "description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式横向网格数量" + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式纵向网格数量" } }, "required": ["prompt"], @@ -1014,6 +1032,9 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { "imageSize", "assetName", "outputPath", + "sliceMode", + "gridX", + "gridY", ], ) { return mcp_tool_result(error, Vec::new(), true); @@ -1041,6 +1062,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { ("imageSize", 4), ("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS), ("outputPath", 512), + ("sliceMode", 32), ] { if arguments.get(field).is_some() { if let Err(error) = bounded_tool_string(arguments, field, max_chars) { @@ -2226,6 +2248,10 @@ mod tests { assert!(image_tool["description"] .as_str() .is_some_and(|description| description.contains("不是本工具的限制"))); + assert_eq!( + image_tool["inputSchema"]["properties"]["sliceMode"]["enum"], + json!(["connected-components", "grid"]) + ); let edit_tool = specs["tools"] .as_array() .expect("tool array") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 2bd540024..f9bbd95a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -413,6 +413,9 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_label: String, pub(crate) replace_existing: bool, pub(crate) slice_count: Option, + pub(crate) slice_mode: Option, + pub(crate) grid_x: Option, + pub(crate) grid_y: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -425,6 +428,9 @@ impl Default for PlatformArtAssetGenerationOptions { asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, } } } @@ -1574,7 +1580,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { warning: Option, slice_warning: Option, slices: Vec, - spritesheet_slice_layout: Option, + spritesheet_slice_mode: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -2213,11 +2219,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( /// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个 /// 动作,必须各自独立成槽,才能在同一项目里同时在途。 /// -/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的 -/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被 -/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。 -/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到 -/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。 +/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集 +/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2229,6 +2232,9 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { asset_label: &'a str, replace_existing: bool, require_slices: bool, + slice_mode: Option<&'a str>, + grid_x: Option, + grid_y: Option, } /// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, @@ -2265,6 +2271,9 @@ fn standalone_platform_art_generation_runtime_context( asset_label: &options.asset_label, replace_existing: options.replace_existing, require_slices, + slice_mode: options.slice_mode.as_deref(), + grid_x: options.grid_x, + grid_y: options.grid_y, }) .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); @@ -2811,6 +2820,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), "sliceCount": options.slice_count, + "sliceMode": options.slice_mode, + "gridX": options.grid_x, + "gridY": options.grid_y, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -3106,8 +3118,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at Vec::new() }; let warning = platform_art_generation_warning(generated); - let spritesheet_slice_layout = if is_canonical_art_spritesheet { - json_string_field(generated, "sliceLayout") + let spritesheet_slice_mode = if is_canonical_art_spritesheet { + json_string_field(generated, "sliceMode") } else { None }; @@ -3168,7 +3180,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at warning, slice_warning, slices, - spritesheet_slice_layout, + spritesheet_slice_mode, generation_route, generation_kind, reference_resource_ids, @@ -6508,7 +6520,7 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, - spritesheet_slice_layout: Option<&str>, + spritesheet_slice_mode: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -6545,7 +6557,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - let _requested_slice_layout = spritesheet_slice_layout; + let _requested_slice_mode = spritesheet_slice_mode; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -7307,7 +7319,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( warning, mut slice_warning, slices, - spritesheet_slice_layout, + spritesheet_slice_mode, generation_route, generation_kind, reference_resource_ids, @@ -7326,7 +7338,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, - spritesheet_slice_layout.as_deref(), + spritesheet_slice_mode.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -7901,8 +7913,8 @@ mod canvas_generation_tests { let body = serde_json::json!({ "error": { "code": "invalid-request", - "field": "sliceLayout", - "message": "只支持 grid-2x2;operationId=private-operation-id;api_key=private-key", + "field": "sliceMode", + "message": "只支持 grid;operationId=private-operation-id;api_key=private-key", }, "details": { "path": "C:\\Users\\private\\secret.json", @@ -7911,8 +7923,8 @@ mod canvas_generation_tests { .to_string(); let summary = summarize_external_http_error_body(&body).expect("summary"); assert!(summary.contains("code=invalid-request"), "{summary}"); - assert!(summary.contains("field=sliceLayout"), "{summary}"); - assert!(summary.contains("只支持 grid-2x2"), "{summary}"); + assert!(summary.contains("field=sliceMode"), "{summary}"); + assert!(summary.contains("只支持 grid"), "{summary}"); assert!(!summary.contains("private-operation-id"), "{summary}"); assert!(!summary.contains("private-key"), "{summary}"); assert!(!summary.contains("C:\\Users\\private"), "{summary}"); @@ -8312,6 +8324,9 @@ mod canvas_generation_tests { asset_label: "手工背景".to_string(), replace_existing: true, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9826,7 +9841,7 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - Some("grid-2x2"), + Some("grid"), &["art-spec-resource".to_string()], true, true, @@ -10325,6 +10340,9 @@ mod canvas_generation_tests { asset_label: "整包规范图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11220,6 +11238,9 @@ mod canvas_generation_tests { asset_label: "整包背景图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11681,6 +11702,9 @@ mod canvas_generation_tests { asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12287,6 +12311,9 @@ mod canvas_generation_tests { asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, } } @@ -12317,7 +12344,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), - spritesheet_slice_layout: Some("grid-2x2".to_string()), + spritesheet_slice_mode: Some("grid".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -12636,7 +12663,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, - spritesheet_slice_layout: Some("grid-2x2".to_string()), + spritesheet_slice_mode: Some("grid".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 132c71c59..6f7aa3441 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -555,6 +555,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("slice_count")) .and_then(serde_json::Value::as_u64) .map(|value| value as usize); + let slice_mode = agent_runtime_tool_input_text(input, &["sliceMode", "slice_mode"]); + let grid_x = input + .get("gridX") + .or_else(|| input.get("grid_x")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as u32); + let grid_y = input + .get("gridY") + .or_else(|| input.get("grid_y")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as u32); let mut requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -563,6 +574,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_label, replace_existing, slice_count, + slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), + grid_x, + grid_y, }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -609,6 +623,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }, replace_existing, slice_count, + slice_mode: requested_options + .slice_mode + .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), + grid_x, + grid_y, } }; options.replace_existing = replace_existing; @@ -654,6 +673,28 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } + if options + .slice_mode + .as_deref() + .is_some_and(|slice_mode| !matches!(slice_mode, "connected-components" | "grid")) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "图片生成 sliceMode 不受支持".to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("grid") + && (options.grid_x.is_none() || options.grid_y.is_none()) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "grid 模式必须同时提供 gridX 与 gridY".to_string(), + detail: None, + }; + } if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index b3cc58f28..828048813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4594,6 +4594,9 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }, }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 28b4dcbc1..f12416e7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3192,7 +3192,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( "spritesheetImageSrc": "/generated/canvas/spritesheet.png", "spritesheetWidth": 2, "spritesheetHeight": 1, - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "iconImageSrcs": icon_image_srcs, "sliceWarning": null, "prompt": "原创游戏素材图集", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 4fa58e0e2..2270ca448 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1076,6 +1076,9 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }, ) .await; @@ -5493,6 +5496,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, slice_count: None, + slice_mode: None, + grid_x: None, + grid_y: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 507747e57..0a28b1ab6 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3370,16 +3370,32 @@ "maxLength": 200 } }, - "sliceLayout": { + "sliceMode": { "type": "string", - "deprecated": true, - "description": "历史兼容字段,新的调用请使用 sliceCount。" + "enum": [ + "connected-components", + "grid" + ], + "default": "connected-components", + "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式的横向网格数量。" + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32, + "description": "grid 模式的纵向网格数量。" }, "sliceCount": { "type": "integer", "minimum": 1, "maximum": 100, - "description": "可选的目标切片数量;省略时按图像内容自动识别。" + "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。" }, "screenColor": { "type": ["string", "null"], @@ -3603,15 +3619,28 @@ }, "iconImageSrcs": { "type": "array", - "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", + "description": "按 sliceMode 识别或裁切并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;connected-components 模式可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, - "sliceLayout": { + "sliceMode": { "type": "string", - "deprecated": true, - "description": "历史兼容字段。" + "enum": [ + "connected-components", + "grid" + ], + "description": "实际采用的图集切分模式。" + }, + "gridX": { + "type": "integer", + "minimum": 1, + "maximum": 32 + }, + "gridY": { + "type": "integer", + "minimum": 1, + "maximum": 32 }, "sliceCount": { "type": "integer", @@ -3628,7 +3657,7 @@ "type": "null" } ], - "description": "可信透明图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。" + "description": "可信透明图集已成功持久化,但所选 sliceMode 的自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量、网格裁切或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-only,sliceWarning 为 null。" }, "prompt": { "type": "string" diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 895286d30..ae769d8b3 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,6 +40,7 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 949fadf99..0805fb4d4 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, slice_count: None, - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, style: None, model: Some(args.model), screen_color: Some("auto".to_string()), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b6b2b1477..d740cdfea 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -93,7 +93,8 @@ use crate::{ }, editor_project_icon::{ EditorIconSpritesheetGenerationResponse, EditorIconSpritesheetIconResponse, - PersistEditorSpritesheetSlicesInput, editor_icon_spritesheet_slice_warning_from_error, + EditorIconSpritesheetSliceMode, PersistEditorSpritesheetSlicesInput, + editor_icon_spritesheet_slice_warning_from_error, editor_icon_spritesheet_warning_after_persist_error, prepare_editor_spritesheet_slices_for_generation, slice_editor_icon_spritesheet_all, }, @@ -1267,7 +1268,7 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetWidth" | "spritesheetHeight" | "iconImageSrcs" - | "sliceLayout" + | "sliceMode" | "frames" | "frameCount" | "frameWidth" @@ -8578,7 +8579,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: None, prompt, @@ -8645,7 +8648,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: None, prompt, @@ -8730,8 +8735,10 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), + EditorIconSpritesheetSliceMode::ConnectedComponents, None, - None, + 0, + 0, ) .await { @@ -8893,7 +8900,9 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning, prompt, @@ -19063,10 +19072,17 @@ mod tests { .checked_sub(Duration::from_millis(1)) .expect("expired deadline should be representable"); - let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None) - .await - .err() - .expect("expired CPU budget must fail before decoding"); + let error = slice_editor_icon_spritesheet_all( + source, + Some(expired), + EditorIconSpritesheetSliceMode::ConnectedComponents, + None, + 0, + 0, + ) + .await + .err() + .expect("expired CPU budget must fail before decoding"); assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); assert_eq!( @@ -19751,7 +19767,9 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), - slice_layout: None, + slice_mode: None, + grid_x: None, + grid_y: None, slice_count: None, slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, @@ -19984,7 +20002,6 @@ mod tests { fn atomic_job_result_keeps_only_the_target_consumer_contract() { let result = json!({ "ok": true, - "sliceLayout": "grid-2x2", "imageSrc": "/api/assets/object/generated.png", "objectKey": "generated/image.png", "width": 512, @@ -20069,7 +20086,6 @@ mod tests { ); assert_eq!(external_payload["result"]["asset"]["assetId"], "asset-1"); assert_eq!(external_payload["result"]["ok"], true); - assert_eq!(external_payload["result"]["sliceLayout"], "grid-2x2"); assert_eq!(external_payload["result"]["prompt"], "用户可见提示词"); assert_eq!( external_payload["result"]["actualPrompt"], diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index f7f318b86..cfecc85be 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -14,7 +14,7 @@ use platform_image::{ generated_asset_sheets::{ GeneratedAssetSheetConnectedIcon, GeneratedAssetSheetConnectedIconPlan, GeneratedAssetSheetError, prepare_generated_icon_spritesheet_all_by_connected_components, - prepare_generated_icon_spritesheet_grid_2x2, + prepare_generated_icon_spritesheet_grid, }, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; @@ -82,6 +82,7 @@ pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; +const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = @@ -254,8 +255,13 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, + /// 图集切分模式;省略时使用连通域切分。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) slice_layout: Option, + pub(crate) slice_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grid_x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grid_y: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) style: Option, pub(crate) model: Option, @@ -270,11 +276,50 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { pub(crate) canvas_completion: Option, } -/// Deprecated compatibility layout. New callers should use `sliceCount`。 #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub(crate) enum EditorIconSpritesheetSliceLayout { - #[serde(rename = "grid-2x2")] - Grid2x2, +#[serde(rename_all = "kebab-case")] +pub(crate) enum EditorIconSpritesheetSliceMode { + ConnectedComponents, + Grid, +} + +impl Default for EditorIconSpritesheetSliceMode { + fn default() -> Self { + Self::ConnectedComponents + } +} + +fn resolve_editor_icon_spritesheet_slice_mode( + slice_mode: Option, +) -> EditorIconSpritesheetSliceMode { + slice_mode.unwrap_or_default() +} + +fn resolve_editor_icon_spritesheet_grid_dimensions( + mode: EditorIconSpritesheetSliceMode, + grid_x: Option, + grid_y: Option, +) -> Result<(u32, u32), AppError> { + if mode == EditorIconSpritesheetSliceMode::ConnectedComponents { + return Ok((0, 0)); + } + let (Some(grid_x), Some(grid_y)) = (grid_x, grid_y) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": "grid 模式必须同时提供 gridX 与 gridY。", + })), + ); + }; + if !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_x) + || !(1..=EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS).contains(&grid_y) + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": format!("gridX 与 gridY 必须在 1 到 {} 之间。", EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS), + }))); + } + Ok((grid_x, grid_y)) } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -313,7 +358,11 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse { pub(crate) spritesheet_height: u32, pub(crate) icon_image_srcs: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) slice_layout: Option, + pub(crate) slice_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) grid_x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) grid_y: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1579,6 +1628,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; + let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode); + let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions( + requested_slice_mode, + payload.grid_x, + payload.grid_y, + )?; // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, @@ -1618,15 +1673,18 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let screen_color = screen_background_decision.color; - let prompt = match payload.slice_layout { - Some(EditorIconSpritesheetSliceLayout::Grid2x2) => { - crate::prompt::icon_spec::build_grid_2x2_spritesheet_prompt( + let slice_mode = requested_slice_mode; + let prompt = match slice_mode { + EditorIconSpritesheetSliceMode::Grid => { + crate::prompt::icon_spec::build_grid_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, + grid_x, + grid_y, ) } - None => crate::prompt::icon_spec::build_spritesheet_prompt( + EditorIconSpritesheetSliceMode::ConnectedComponents => crate::prompt::icon_spec::build_spritesheet_prompt( &spritesheet_prompt, screen_color, icon_spec_genre, @@ -1786,7 +1844,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: payload.slice_layout, + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, @@ -1868,7 +1930,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width: source_width, spritesheet_height: source_height, icon_image_srcs: Vec::new(), - slice_layout: payload.slice_layout, + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(0), slice_warning: None, prompt, @@ -1966,8 +2032,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( let (mut icon_image_srcs, slice_items, slice_warning) = match slice_editor_icon_spritesheet_all( slice_source, request_context.external_call_deadline(), - payload.slice_layout, + requested_slice_mode, payload.slice_count, + grid_x, + grid_y, ) .await { @@ -2059,7 +2127,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheetWidth": spritesheet_width, "spritesheetHeight": spritesheet_height, "iconImageSrcs": &icon_image_srcs, - "sliceLayout": payload.slice_layout, + + "sliceMode": requested_slice_mode, + "gridX": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + "gridY": (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), "sliceCount": payload.slice_count, "sliceWarning": &slice_warning, "warning": &generation_warning, @@ -2137,7 +2210,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, - slice_layout: payload.slice_layout, + + slice_mode: Some(requested_slice_mode), + grid_x: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_x), + grid_y: (requested_slice_mode == EditorIconSpritesheetSliceMode::Grid) + .then_some(grid_y), slice_count: Some(slice_count), slice_warning, prompt, @@ -2332,8 +2410,10 @@ pub async fn split_editor_icon_spritesheet( source, processing_deadline, memory_admission, + EditorIconSpritesheetSliceMode::ConnectedComponents, None, - None, + 0, + 0, ) .await?; let prompt = source_resource @@ -2408,8 +2488,10 @@ pub async fn split_editor_icon_spritesheet( pub(crate) async fn slice_editor_icon_spritesheet_all( source: DownloadedImage, request_deadline: Option, - slice_layout: Option, + slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, + grid_x: u32, + grid_y: u32, ) -> Result { let processing_deadline = resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline); @@ -2419,8 +2501,10 @@ pub(crate) async fn slice_editor_icon_spritesheet_all( source, processing_deadline, memory_admission, - slice_layout, + slice_mode, slice_count, + grid_x, + grid_y, ) .await } @@ -2458,8 +2542,10 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( source: DownloadedImage, processing_deadline: Instant, memory_admission: Arc, - slice_layout: Option, + slice_mode: EditorIconSpritesheetSliceMode, slice_count: Option, + grid_x: u32, + grid_y: u32, ) -> Result { if Instant::now() >= processing_deadline { return Err(editor_icon_spritesheet_processing_timeout_error()); @@ -2492,17 +2578,19 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission( return Err(editor_icon_spritesheet_processing_timeout_error()); } validate_editor_icon_spritesheet_source(&source)?; - match slice_layout { - Some(EditorIconSpritesheetSliceLayout::Grid2x2) => { - prepare_generated_icon_spritesheet_grid_2x2(&source) + match slice_mode { + EditorIconSpritesheetSliceMode::Grid => { + prepare_generated_icon_spritesheet_grid(&source, grid_x, grid_y) + } + EditorIconSpritesheetSliceMode::ConnectedComponents => { + prepare_generated_icon_spritesheet_all_by_connected_components( + &source, + slice_count + .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) + .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), + EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, + ) } - None => prepare_generated_icon_spritesheet_all_by_connected_components( - &source, - slice_count - .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES) - .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES), - EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS, - ), } .map_err(map_editor_icon_spritesheet_platform_error) }); @@ -2909,7 +2997,7 @@ mod tests { } #[tokio::test] - async fn grid_2x2_slicing_returns_exactly_four_quadrants_with_detached_details() { + async fn grid_slicing_returns_one_slice_per_declared_cell_with_detached_details() { use image::{ImageBuffer, ImageFormat, Rgba}; let mut image: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255])); @@ -2939,15 +3027,54 @@ mod tests { let prepared = slice_editor_icon_spritesheet_all( source, None, - Some(EditorIconSpritesheetSliceLayout::Grid2x2), + EditorIconSpritesheetSliceMode::Grid, None, + 2, + 2, ) .await - .expect("declared 2x2 sheet should slice"); + .expect("declared grid sheet should slice"); assert_eq!(prepared.plan.len(), 4); } + #[test] + fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() { + assert_eq!( + resolve_editor_icon_spritesheet_slice_mode(None), + EditorIconSpritesheetSliceMode::ConnectedComponents + ); + assert_eq!( + resolve_editor_icon_spritesheet_grid_dimensions( + EditorIconSpritesheetSliceMode::Grid, + Some(3), + Some(2), + ) + .expect("grid dimensions should validate"), + (3, 2) + ); + let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"], + "sliceMode": "connected-components" + })) + .expect("explicit connected-components mode should deserialize"); + assert_eq!( + connected.slice_mode, + Some(EditorIconSpritesheetSliceMode::ConnectedComponents) + ); + let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"], + "sliceMode": "grid", + "gridX": 3, + "gridY": 2 + })) + .expect("grid mode should deserialize"); + assert_eq!(grid.grid_x, Some(3)); + assert_eq!(grid.grid_y, Some(2)); + } + #[test] fn spritesheet_genre_requires_exact_game_type_title() { assert_eq!( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index de48a2d15..eeddb4371 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2620,6 +2620,14 @@ mod tests { icon_spritesheet_request["properties"]["sliceCount"]["minimum"], json!(1) ); + assert_eq!( + icon_spritesheet_request["properties"]["sliceMode"]["enum"], + json!(["connected-components", "grid"]) + ); + assert_eq!( + icon_spritesheet_request["properties"]["gridX"]["maximum"], + json!(32) + ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; assert_eq!(icon_style_schema["anyOf"][0]["type"], "string"); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index b279b5054..6c89e8fb2 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1364,7 +1364,9 @@ fn compact_external_api_generation_result(result: Value) -> Value { | "spritesheetWidth" | "spritesheetHeight" | "iconImageSrcs" - | "sliceLayout" + | "sliceMode" + | "gridX" + | "gridY" | "sliceCount" | "frames" | "frameCount" @@ -2436,7 +2438,9 @@ mod tests { let mut job = external_generation_job_record_fixture(Some("lease-1")); job.dedupe_key = "external-api-generation:conversation-1:7:icon-spritesheet".to_string(); let response = json!({ - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "iconImageSrcs": [ { "name": "素材 1", "imageSrc": "/api/assets/object/one.png" }, { "name": "素材 2", "imageSrc": "/api/assets/object/two.png" }, @@ -2449,7 +2453,7 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("worker result should be valid JSON"); - assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!(payload["result"]["sliceMode"], json!("grid")); assert_eq!( payload["result"]["iconImageSrcs"].as_array().map(Vec::len), Some(4) @@ -2705,7 +2709,9 @@ mod tests { "spritesheetImageSrc": "/api/assets/object/core-sheet.png", "spritesheetWidth": 1024, "spritesheetHeight": 1024, - "sliceLayout": "grid-2x2", + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, "spritesheetResource": { "resourceId": "sheet-resource-1", "objectKey": "users/user-1/core-sheet.png", @@ -2729,7 +2735,7 @@ mod tests { serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) .expect("游戏创作客户端完成结果应持久化为合法 JSON"); - assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!(payload["result"]["sliceMode"], json!("grid")); assert_eq!( payload["result"]["iconImageSrcs"].as_array().map(Vec::len), Some(4) diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 5ee492476..6b6c469f7 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str = "genarrative://external-editor/skill/references/requests-and-outputs.md"; const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; -const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; +const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; #[derive(Clone, Debug)] struct McpOperation { diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index 51469fb1b..c68cacb2e 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -276,13 +276,17 @@ pub(crate) fn build_spritesheet_prompt( ) } -pub(crate) fn build_grid_2x2_spritesheet_prompt( +pub(crate) fn build_grid_spritesheet_prompt( user_prompt: &ValidatedEditorIconSpritesheetPrompt, screen_color: EditorScreenBackgroundColor, genre: Option, + columns: u32, + rows: u32, ) -> String { format!( - "{}\n\n固定 2×2 游戏核心素材图集合同:画面必须严格分为左上、右上、左下、右下四个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。四个槽位必须按用户给出的四条素材需求顺序对应,且每格都必须有清晰可见的主体。禁止生成任何额外图标、同一主体的多个姿势、序列帧、棋盘、场景、边框、流程箭头、标签、文字、Logo、装饰小物或第五个素材;禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。", + "{}\n\n固定网格游戏核心素材图集合同:画面必须严格分为 {}×{} 个等大的独立槽位;每个槽位只放一个完整、可单独用于游戏运行时的主体。素材按从左到右、从上到下顺序对应,禁止主体跨格、触碰或重叠。输出须是单张图集,背景只使用统一纯色以便透明化。", + columns, + rows, build_spritesheet_prompt(user_prompt, screen_color, genre), ) } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index aeffb002d..96982edaa 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -21,7 +21,7 @@ pub use sheet::{ GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, prepare_generated_icon_spritesheet_all_by_connected_components, - prepare_generated_icon_spritesheet_grid_2x2, slice_generated_asset_sheet, + prepare_generated_icon_spritesheet_grid, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, slice_generated_icon_spritesheet_all_by_connected_components, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index b0d7ec81b..ad75b55ce 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -254,35 +254,38 @@ pub fn prepare_generated_icon_spritesheet_all_by_connected_components( ) } -/// Prepares the four authoritative slices of a provider-generated 2×2 atlas. +/// Prepares the authoritative slices of a provider-generated rectangular grid atlas. /// /// This deliberately does not inspect connected components. A caller that /// requested the fixed layout has already constrained the provider to place /// one complete asset in every quadrant; trying to infer individual pieces /// again would turn highlights and detached effects into extra game assets. -pub fn prepare_generated_icon_spritesheet_grid_2x2( +pub fn prepare_generated_icon_spritesheet_grid( image: &crate::DownloadedImage, + columns: u32, + rows: u32, ) -> Result { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { GeneratedAssetSheetError::decode_image(format!("图标 spritesheet 解码失败:{error}")) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source).into_rgba8(); let (width, height) = source.dimensions(); - if width < 2 || height < 2 { + if columns == 0 || rows == 0 || width < columns || height < rows { return Err(GeneratedAssetSheetError::invalid_request( - "2×2 图标 spritesheet 尺寸过小,无法切割。", + "网格图标 spritesheet 的尺寸或网格参数无效,无法切割。", )); } - let mut icons = Vec::with_capacity(4); - for index in 0..4usize { - let row = (index / 2) as u32; - let col = (index % 2) as u32; - let cell = resolve_generated_asset_sheet_cell_bounds(width, height, 2, row, col); + let cell_count = columns.saturating_mul(rows) as usize; + let mut icons = Vec::with_capacity(cell_count); + for index in 0..cell_count { + let row = (index as u32) / columns; + let col = (index as u32) % columns; + let cell = resolve_generated_asset_sheet_cell_bounds(width, height, columns, row, col); let foreground = detect_generated_asset_sheet_visible_bounds_in_cell(&source, cell) .ok_or_else(|| { GeneratedAssetSheetError::invalid_request(format!( - "2×2 图标 spritesheet 的第 {} 个格子没有可见素材。", + "网格图标 spritesheet 的第 {} 个格子没有可见素材。", index + 1 )) })?; @@ -295,7 +298,7 @@ pub fn prepare_generated_icon_spritesheet_grid_2x2( y1: foreground.y1.saturating_add(pad_y).min(cell.y1), }; icons.push(GeneratedAssetSheetConnectedIconPlanItem { - name: format!("2×2 素材 {}", index + 1), + name: format!("网格素材 {}", index + 1), crop, }); } @@ -1009,7 +1012,7 @@ mod tests { } #[test] - fn grid_2x2_plan_keeps_one_durable_slice_per_quadrant_despite_detached_details() { + fn grid_plan_keeps_one_durable_slice_per_cell_despite_detached_details() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 128, Rgba([0, 255, 0, 255])); let colors = [ [240, 80, 80, 255], @@ -1036,8 +1039,8 @@ mod tests { extension: "png".to_string(), }; - let plan = prepare_generated_icon_spritesheet_grid_2x2(&source) - .expect("declared 2x2 atlas should prepare"); + let plan = prepare_generated_icon_spritesheet_grid(&source, 2, 2) + .expect("declared grid atlas should prepare"); let icons = (0..plan.len()) .map(|index| plan.encode(index).expect("quadrant should encode")) .collect::>(); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index eae2857aa..d846e24e3 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1147,13 +1147,14 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-icon-spec', referenceImageSrcs: references, iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ - body: expect.stringContaining( - '"model":"gemini-3.1-flash-image-preview"', + body: expect.stringMatching( + /(?=.*"model":"gemini-3\.1-flash-image-preview")(?=.*"sliceMode":"connected-components")/, ), }), '生成图标素材失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 6ad26770f..e84825190 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -380,6 +380,9 @@ export type EditorIconSpritesheetGenerationInput = { referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; + sliceMode?: 'connected-components' | 'grid'; + gridX?: number; + gridY?: number; model?: string; screenColor?: string; segModel?: string; @@ -516,6 +519,9 @@ export type EditorIconSpritesheetGenerationResult = { spritesheetWidth: number; spritesheetHeight: number; iconImageSrcs: EditorIconSpritesheetIconResult[]; + sliceMode?: 'connected-components' | 'grid'; + gridX?: number; + gridY?: number; sliceWarning?: EditorIconSpritesheetSliceWarning | null; prompt: string; actualPrompt?: string | null; @@ -1311,6 +1317,9 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions, + ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}), + ...(input.gridX !== undefined ? { gridX: input.gridX } : {}), + ...(input.gridY !== undefined ? { gridY: input.gridY } : {}), model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), From 361daf374dfa64a1c1ef21e58cf268de9199faeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Tue, 15 Sep 2026 20:21:53 +0800 Subject: [PATCH 05/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=94=99=E8=AF=AF=E8=AF=8A=E6=96=AD=E4=B8=8E=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E9=AA=8C=E6=94=B6=20(#376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 新增统一 Agent Runtime 错误事件和项目内诊断 sidecar,统一 DirectProject、Agent Runtime、app-server 与 AGC 工具桥的失败记录。 - 失败回执写回 DirectProject 对话历史,下一轮可读取上一轮失败证据;前端支持读取脱敏诊断详情。 - 区分 app-server 终态、空闲/硬超时、工具参数错误和试玩次数越界。 - 素材完成门扫描实际 npm 源码模块,并识别 manifest 中合法的自定义登记素材路径。 - 更新 AGC 技术规范、项目决策记录、里程碑和实施计划。 ## 验证 - cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests - 统一错误测试 6/6 通过 - DirectProject 失败诊断测试 4/4 通过 - npm 场景模块素材引用回归测试通过 - npm run --prefix apps/ai-game-creator-shell typecheck - npm run check:encoding - npm run check:doc-index - git diff --check、Rust 格式检查 真实 Provider / 安装包 smoke 尚未在本 PR 中运行。 --------- Co-authored-by: Git Hooks Test Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/376 --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 53 +++++- .../src-tauri/src/agent/direct_runtime.rs | 176 +++++++++++++++--- .../src-tauri/src/agent/direct_tool_bridge.rs | 28 ++- .../src-tauri/src/agent/direct_tools_mcp.rs | 4 +- .../src-tauri/src/agent/runtime_error.rs | 167 +++++++++++++++++ .../src-tauri/src/agent/runtime_state.rs | 20 ++ .../src-tauri/src/commands.rs | 32 ++++ .../src-tauri/src/main.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 19 +- ...计划】AGC统一错误诊断与验收反馈-2026-09-15.md | 35 ++++ ...碑】AGC统一错误诊断与验收反馈-2026-09-15.md | 39 ++++ .../shared-memory/decision-log.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 12 ++ 14 files changed, 561 insertions(+), 36 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs create mode 100644 docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 17b8a6700..b9d90f269 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -28,6 +28,7 @@ mod prompt; mod runtime_actions; mod runtime_adapter; mod runtime_driver; +mod runtime_error; mod runtime_protocol; mod runtime_state; mod runtime_tools; @@ -56,6 +57,7 @@ pub(crate) use prompt::*; pub(crate) use runtime_actions::*; pub(crate) use runtime_adapter::*; pub(crate) use runtime_driver::*; +pub(crate) use runtime_error::*; pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 48edc498f..ea4d981a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -271,6 +271,51 @@ fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmErro )) } +fn game_creator_codex_app_server_error_kind_with_machine_detail( + kind: &str, + error: &serde_json::Value, +) -> platform_llm::LlmError { + let mut fields = Vec::new(); + if let Some(object) = error.as_object() { + if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { + if !code.is_empty() + && code.len() <= 80 + && code + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + fields.push(format!("code={code}")); + } + } + let keys = object + .keys() + .filter(|key| { + matches!( + key.as_str(), + "httpConnectionFailed" + | "responseStreamConnectionFailed" + | "responseStreamDisconnected" + | "responseTooManyFailedAttempts" + | "activeTurnNotSteerable" + | "codexErrorInfo" + ) + }) + .cloned() + .collect::>(); + if !keys.is_empty() { + fields.push(format!("fields={}", keys.join(","))); + } + } + let suffix = if fields.is_empty() { + String::new() + } else { + format!(" detail={}", fields.join(" ")) + }; + platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}{suffix}" + )) +} + fn game_creator_codex_app_server_error_http_status( info: &serde_json::Value, field: &str, @@ -425,7 +470,7 @@ fn game_creator_codex_app_server_failed_turn_error( return game_creator_codex_app_server_error_kind("unauthorized"); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { - return game_creator_codex_app_server_error_kind("other"); + return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); }; if let Some(kind) = info.as_str() { return match kind { @@ -449,8 +494,8 @@ fn game_creator_codex_app_server_failed_turn_error( game_creator_codex_app_server_error_kind("thread-rollback-failed") } "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), - "other" => game_creator_codex_app_server_error_kind("other"), - _ => game_creator_codex_app_server_error_kind("other"), + "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), + _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; } for field in [ @@ -466,7 +511,7 @@ fn game_creator_codex_app_server_failed_turn_error( if info.get("activeTurnNotSteerable").is_some() { return game_creator_codex_app_server_error_kind("active-turn-not-steerable"); } - game_creator_codex_app_server_error_kind("other") + game_creator_codex_app_server_error_kind_with_machine_detail("other", error) } async fn isolate_game_creator_codex_app_server_terminal_unknown( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 71cf43b44..64343f79f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1905,7 +1905,11 @@ fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { || normalized.contains("insufficient-mud-points") } -fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { +fn record_direct_codex_turn_failure( + root: &Path, + failure: DirectCodexTurnFailure, + client_turn_id: Option<&str>, +) -> String { let summary = direct_codex_failure_public_summary(&failure.error) .map(str::to_string) .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); @@ -1942,18 +1946,53 @@ fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure } else { "未能保存项目诊断" }; - format!( - "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}", + let error_code = classify_direct_codex_error(&failure.error); + let unified_detail_ref = persist_agent_runtime_error( + root, + client_turn_id, + "direct-codex", failure.stage.id(), + error_code, + retryable, + &summary, + recovery_hint, + &failure.error, + None, + serde_json::json!({ + "legacyDiagnosticWritten": diagnostic_written, + }), + ) + .ok() + .map(|event| event.detail_ref); + format!( + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}", + failure.stage.id(), + error_code, retryable, diagnostic["summary"] .as_str() .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, + unified_detail_ref + .map(|path| format!(";详情:{path}")) + .unwrap_or_default(), ) } +fn persist_direct_codex_failure_context( + root: &Path, + client_turn_id: &str, + error: &str, +) -> Result<(), String> { + let item = direct_project_local_message_item( + "assistant", + error, + Some(&format!("direct-codex:{client_turn_id}:failure")), + )?; + append_direct_project_history_item_at(root, &item) +} + fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, @@ -2269,9 +2308,19 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { } fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { - let sources = direct_codex_game_outputs(root) + let mut source_paths = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) + .map(|(relative_path, _, _)| relative_path) + .collect::>(); + // npm/Phaser projects put the actual scene and loader code below `game/src`. + // Keep the canonical output list for manifest projection, but scan the + // complete bounded source list for the asset reference contract. + source_paths.extend(direct_npm_source_paths(root)); + source_paths.sort(); + source_paths.dedup(); + let sources = source_paths + .into_iter() + .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2284,6 +2333,28 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH.to_string()); } available_paths.extend(direct_registered_taonier_slice_paths(root)); + // A project may have a valid, client-registered art-spritesheet at a + // project-specific path (for example a generated building sheet). The + // fixed canonical package paths above are compatibility candidates only; + // the manifest is the authority for additional runtime image identities. + if let Ok(manifest) = read_manifest_for_project(root) { + available_paths.extend( + manifest + .assets + .into_iter() + .filter(|asset| { + matches!( + asset.kind.as_str(), + "art-spritesheet" | "art-spritesheet-slice" | "game-background" + ) && asset.media_type == "image/png" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && asset.local_path.starts_with("assets/") + }) + .map(|asset| asset.local_path), + ); + } + available_paths.sort(); + available_paths.dedup(); available_paths .into_iter() .filter(|path| sources.iter().any(|source| source.contains(path.as_str()))) @@ -4087,7 +4158,17 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( { Ok(reply) => Ok(reply), Err(failure) => { - let error = record_direct_codex_turn_failure(root, failure); + let error = record_direct_codex_turn_failure( + root, + failure, + turn_emitter.map(|emitter| emitter.turn_id()), + ); + if let Some(emitter) = turn_emitter { + // Persist the safe terminal projection so the next DirectProject + // turn can answer a diagnostic question from evidence instead of + // guessing or starting another playtest. + let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); + } if let Some(emitter) = turn_emitter { emitter.emit("failed", Some("none"), None); } @@ -6620,26 +6701,27 @@ mod tests { #[test] fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), + None, ); assert!(error - .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary=")); + .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); assert!(!error.contains("?token=secret"), "{error}"); assert!(!error.contains("provider.example"), "{error}"); - let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics"); let entries = std::fs::read_dir(&diagnostics) .expect("diagnostic directory") .filter_map(Result::ok) @@ -6658,25 +6740,25 @@ mod tests { #[test] fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let history_path = root - .path() .join(".agent/conversations/project.jsonl") .display() .to_string(); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), + None, ); assert!( error.starts_with( - "direct-codex-failure:v1 stage=code-generation retryable=false summary=" + "direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=" ), "{error}" ); @@ -6686,9 +6768,9 @@ mod tests { ), "{error}" ); - assert!(error.ends_with("已保存脱敏项目诊断"), "{error}"); + assert!(error.contains("已保存脱敏项目诊断"), "{error}"); - let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics"); let entries = std::fs::read_dir(&diagnostics) .expect("diagnostic directory") .filter_map(Result::ok) @@ -6702,19 +6784,20 @@ mod tests { #[test] fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), + None, ); assert!( - error.contains("stage=art-preparation retryable=false"), + error.contains("stage=art-preparation code=runtime-failure retryable=false"), "{error}" ); assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); @@ -6722,15 +6805,16 @@ mod tests { #[test] fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() { - let root = tempfile::tempdir().expect("temp dir"); - init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") - .expect("init project"); + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_turn_failure( - root.path(), + &root, DirectCodexTurnFailure::new( DirectCodexFailureStage::ArtPreparation, "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), + None, ); assert!( @@ -7830,6 +7914,40 @@ mod tests { .any(|warning| warning.contains("不得猜测切片"))); } + #[test] + fn direct_completion_scans_npm_scene_modules_for_registered_asset_references() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-src-runtime", "源码模块素材引用") + .expect("init project"); + register_direct_taonier_art_package_fixture(&root); + register_direct_taonier_art_slice_entries_fixture(&root); + std::fs::write( + root.join("game/package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .expect("package"); + std::fs::write(root.join("game/index.html"), "").expect("index"); + std::fs::write(root.join("game/style.css"), "body {}").expect("style"); + std::fs::write(root.join("game/game.js"), "import './src/scene.js';").expect("entry"); + std::fs::create_dir_all(root.join("game/src")).expect("src dir"); + std::fs::write( + root.join("game/src/scene.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';", + ) + .expect("scene"); + std::fs::write( + root.join("assets/art-spritesheet-slices/player.png"), + tiny_opaque_png(), + ) + .expect("slice"); + + assert_eq!( + direct_game_sources_referenced_taonier_assets(&root), + vec!["assets/art-spritesheet-slices/player.png".to_string()] + ); + } + #[test] fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 6c94bfe6a..12c6f8d27 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1078,7 +1078,9 @@ fn bridge_attempt(arguments: &Value) -> Result { .and_then(Value::as_u64) .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; if !(1..=3).contains(&attempt) { - return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + return Err(format!( + "playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果" + )); } Ok(attempt as usize) } @@ -2608,6 +2610,30 @@ async fn handle_direct_tool_bridge( } _ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true), }; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + let message = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .unwrap_or("客户端工具执行失败"); + let code = if message.contains("playtest-attempt-limit-exceeded") { + "playtest-attempt-limit-exceeded" + } else { + "tool-error" + }; + let _ = persist_agent_runtime_error( + &state.root, + None, + "agc-tools", + "tool-execution", + code, + true, + message, + "查看项目错误诊断后处理", + message, + None, + serde_json::json!({"tool": request.tool}), + ); + } Json(result) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 294b44c25..1005ed813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -898,7 +898,9 @@ fn tool_attempt(arguments: &Value) -> Result { .and_then(Value::as_u64) .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; if !(1..=3).contains(&attempt) { - return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + return Err(format!( + "playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果" + )); } Ok(attempt as usize) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs new file mode 100644 index 000000000..4fe895680 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -0,0 +1,167 @@ +//! Shared, project-bound error events for Agent Runtime and DirectProject. +//! +//! Every caller supplies a safe public summary and a private detail. This +//! module is the only persistence boundary for the latter: it redacts project +//! paths and credentials before writing a bounded diagnostic sidecar. + +use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1"; +pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; + +static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub(crate) struct AgentRuntimeErrorEvent { + pub schema_version: &'static str, + pub event_id: String, + pub client_turn_id: Option, + pub source: String, + pub stage: String, + pub code: String, + pub retryable: bool, + pub occurred_at_unix_nanos: String, + pub elapsed_ms: Option, + pub public_text: String, + pub recovery_hint: String, + pub detail_ref: String, + pub persistence_failed: bool, + pub metadata: Value, +} + +pub(crate) fn persist_agent_runtime_error( + root: &Path, + client_turn_id: Option<&str>, + source: &str, + stage: &str, + code: &str, + retryable: bool, + public_text: &str, + recovery_hint: &str, + detail: &str, + elapsed_ms: Option, + metadata: Value, +) -> Result { + let occurred_at_unix_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("读取错误事件时间失败:{error}"))? + .as_nanos(); + let sequence = ERROR_EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let event_id = format!("error-{occurred_at_unix_nanos}-{sequence}"); + let detail_ref = format!(".agent/runtime/errors/{event_id}.json"); + let safe_detail = + redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS); + let diagnostic = serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION, + "eventId": event_id, + "clientTurnId": client_turn_id, + "source": source, + "stage": stage, + "code": code, + "retryable": retryable, + "occurredAtUnixNanos": occurred_at_unix_nanos.to_string(), + "elapsedMs": elapsed_ms, + "publicText": public_text, + "recoveryHint": recovery_hint, + "detail": safe_detail, + "metadata": metadata, + }); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &detail_ref, + "统一 Agent Runtime 错误诊断", + &diagnostic, + 16 * 1024, + )?; + Ok(AgentRuntimeErrorEvent { + schema_version: AGENT_RUNTIME_ERROR_SCHEMA_VERSION, + event_id, + client_turn_id: client_turn_id.map(str::to_string), + source: source.to_string(), + stage: stage.to_string(), + code: code.to_string(), + retryable, + occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(), + elapsed_ms, + public_text: public_text.to_string(), + recovery_hint: recovery_hint.to_string(), + detail_ref, + persistence_failed: false, + metadata, + }) +} + +pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if normalized.contains("等待 turn/completed 超时") { + "turn-idle-timeout" + } else if normalized.contains("达到 directproject 硬上限") { + "turn-hard-timeout" + } else if normalized.contains("transport closed") || normalized.contains("连接已关闭") { + "transport-closed" + } else if normalized.contains("playtest-attempt-limit-exceeded") { + "playtest-attempt-limit-exceeded" + } else if (normalized.contains("tool") || normalized.contains("工具")) + && normalized.contains("参数") + { + "tool-invalid-arguments" + } else if normalized.contains("codex app-server-error:other") { + "app-server-other" + } else { + "runtime-failure" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_event_is_bounded_and_redacts_private_detail() { + let parent = tempfile::tempdir().expect("temp root"); + let root = parent.path().join("project"); + crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件") + .expect("init project"); + let event = persist_agent_runtime_error( + &root, + Some("turn-123"), + "direct-codex", + "code-generation", + "turn-idle-timeout", + true, + "本轮没有收到完成事件", + "查看诊断后重试", + "C:\\Users\\private\\project https://provider.example/a?token=secret", + Some(1200), + serde_json::json!({"lastEvent":"item/started"}), + ) + .expect("persist event"); + assert_eq!(event.code, "turn-idle-timeout"); + let path = root.join(&event.detail_ref); + let text = std::fs::read_to_string(path).expect("diagnostic"); + assert!(text.contains("")); + assert!(text.contains("")); + assert!(!text.contains("token=secret")); + } + + #[test] + fn timeout_and_tool_errors_have_distinct_codes() { + assert_eq!( + classify_direct_codex_error("等待 turn/completed 超时"), + "turn-idle-timeout" + ); + assert_eq!( + classify_direct_codex_error("达到 DirectProject 硬上限"), + "turn-hard-timeout" + ); + assert_eq!( + classify_direct_codex_error("工具参数 attempt 必须是 1 到 3 的整数"), + "tool-invalid-arguments" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index f23bf5f5b..ede8bb200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -101,6 +101,26 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at( error: &str, ) -> Result<(), String> { let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error); + // Keep the existing conversation projection, but also persist one common + // bounded diagnostic event for every Agent Runtime terminal failure. This + // makes non-DirectProject failures observable through the same detail API. + let _ = persist_agent_runtime_error( + root, + Some(&state.run_id), + "agent-runtime", + &state.phase, + "agent-runtime-terminal", + false, + &content, + "查看项目错误诊断后处理", + error, + None, + serde_json::json!({ + "agentId": state.agent_id, + "sessionId": state.session_id, + "runId": state.run_id, + }), + ); let status = if state.phase == "budget-exhausted" { "budget-exhausted" } else if state.phase == "needs-reconciliation" { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 828048813..53c77d151 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5244,6 +5244,38 @@ pub(crate) async fn read_direct_project_conversation( .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) async fn read_agent_runtime_error_detail( + project_path: String, + detail_ref: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let relative = detail_ref.trim(); + let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { + return Err("错误诊断引用不在项目错误目录内".to_string()); + }; + if file_name.is_empty() + || file_name.contains(['/', '\\']) + || file_name.contains("..") + || !file_name.ends_with(".json") + { + return Err("错误诊断引用格式无效".to_string()); + } + let path = root.join(relative); + prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; + let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; + if bytes.len() > 16 * 1024 { + return Err("错误诊断超过读取上限".to_string()); + } + let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; + Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) + }) + .await + .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2f2f5b7e2..b95ebf358 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2768,6 +2768,7 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, + read_agent_runtime_error_detail, append_local_conversation_message, append_direct_project_conversation_message, build_local_project_index, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index d1b1cf38c..723c0429c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6519,8 +6519,25 @@ export function App({ void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); const message = error instanceof Error ? error.message : String(error); + let persistedDetail = ''; + const detailRef = message.match( + /详情:(\.agent\/runtime\/errors\/[^\s;]+)/, + )?.[1]; + if (detailRef && directInvoke) { + try { + persistedDetail = await directInvoke( + 'read_agent_runtime_error_detail', + { + projectPath: directProjectPath, + detailRef, + }, + ); + } catch { + persistedDetail = ''; + } + } const visibleMessage = projectRuntimeVisibleError( - message, + persistedDetail ? `${message}\n\n${persistedDetail}` : message, '陶泥儿智能创作', true, ); diff --git a/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md new file mode 100644 index 000000000..031cd576c --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -0,0 +1,35 @@ +# AGC 统一错误诊断与验收反馈实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-15 +Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` + +## 修改边界 + +1. 新增 `agent/runtime_error.rs`,承载统一事件字段、code/stage 白名单、脱敏后的 public projection、项目错误 JSONL/sidecar 落库和 detail 读取边界。 +2. `direct_runtime.rs` 使用统一事件替代仅写 `failure.json` 的路径;失败 assistant 投影带稳定 ID,下一轮 prompt 注入最近失败事件摘要。 +3. `codex_app_server.rs` 将 failed turn、idle/hard timeout、transport close、invalid terminal 和 stderr tail 转成稳定事件字段;不公开原始 detail。 +4. `direct_tool_bridge.rs` 与 `direct_tools_mcp.rs` 让 attempt 由客户端回合状态约束,越界请求返回终态工具错误;不扩展重试预算。 +5. `direct_runtime.rs` 的素材扫描递归覆盖可执行源码模块,基于 manifest 身份和浏览器 URL 映射判定;补充模块引用回归测试。 +6. 前端读取后端 `publicText/detailRef`,在现有 Runtime 错误面板中加入详情入口;不在 React 侧重新分类错误。 + +## 实现顺序 + +先写统一事件模型和 Rust 单测,再接 direct failure/app-server/tool bridge,随后接 prompt/history 与前端详情,最后修素材验收和 attempt 生命周期。每一步保留原有脱敏和失败关闭行为。 + +## 验证命令 + +- `cargo fmt --check` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error direct_runtime codex_app_server direct_tool_bridge` +- `npm run --prefix apps/ai-game-creator-shell typecheck` +- `npm run check:encoding` +- `git diff --check` +- 必要时运行 AGC deterministic playable E2E;真实 Provider smoke 与浏览器双视口 smoke 单独报告。 + +## 风险与回滚 + +- 统一事件 schema 只新增项目内文件和对话投影,不修改已有 manifest、公开 API 或 SpacetimeDB schema。 +- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。 +- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。 +- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。 diff --git a/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md new file mode 100644 index 000000000..bcc95ef90 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md @@ -0,0 +1,39 @@ +# AGC 统一错误诊断与验收反馈 + +Version: 1.0 +Status: active +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈” + +## 目标 + +让 DirectProject 和共享 Agent Runtime 对失败使用同一份安全、可追踪、可恢复的错误事件合同;用户追问失败原因时能够读取上一轮证据;构建与浏览器验收只依据真实源码、manifest 身份和运行时证据判断。 + +## 范围 + +- 统一错误事件模型与项目内诊断落库。 +- DirectProject 失败 assistant 投影、下一轮诊断上下文和前端详情入口。 +- app-server 终态/超时、内置 MCP 工具错误和试玩 attempt 上限的分类。 +- 游戏源码模块素材扫描、manifest 身份映射与浏览器观察映射。 +- 定向 Rust/前端回归和现有 AGC 运行时门禁。 + +## 不做 + +- 不改变 Provider、External Editor 或 app-server 的 wire 协议。 +- 不放宽项目写锁、凭据隔离、工具白名单或完成门安全边界。 +- 不迁移历史项目文件;旧诊断只读兼容,新增事件使用新 schema。 +- 不把原始 stderr、请求正文或绝对路径展示给用户。 + +## 验收标准 + +1. 任一 DirectProject 失败均生成统一事件、稳定 `eventId` 和有界诊断引用;落库失败不覆盖原始错误。 +2. 失败安全投影写入对话历史,下一轮能读取 `publicText / code / stage / detailRef`,不会因追问而自动试玩。 +3. 结构化 failed turn、idle/hard timeout、transport close、MCP 参数错误和 `other` 各有稳定 code 与 recoveryHint。 +4. `attempt` 由客户端按回合分配并有上限;越界调用不会让回合继续等待。 +5. `game/src` 下模块引用已登记素材、Vite dist 稳定映射和浏览器实际观察均能通过;未登记素材仍失败。 +6. 脱敏测试证明 Token、Cookie、URL/query、私钥、宿主绝对路径和 stderr 私密内容不会进入用户文本。 + +## 依赖 + +- 现有 `direct_project_history`、`runtime_state`、`codex_app_server`、`direct_tool_bridge` 与浏览器 validation 证据。 +- 现有 DirectProject 诊断 sidecar 和 manifest 资源身份。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d18fbd8ab..bd8416d40 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8741,6 +8741,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`(+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**:external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。 - 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a / §7.3)。 + ## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken - 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。 @@ -8751,3 +8752,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。 - 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL`、`AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。 + +## 2026-09-15 AGC 统一错误事件与项目诊断落库 + +- 背景:DirectProject 的 app-server 超时、MCP 参数错误、浏览器完成门误判和普通 Agent Runtime 失败分别投影为短文案;失败正文没有稳定落库,下一轮模型看不到上一轮失败证据,用户追问原因时可能继续试玩或重复修改。 +- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 +- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 +- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 + diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 657dcb9fc..3f091c118 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1379,3 +1379,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 2026-09-14 新游戏策划到真实美术接入的连续交付 DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视觉素材作为同一交付链路处理:先读取当前项目已登记资源;策划案包含角色、对象、背景、特效、界面或其它视觉实体且现有资源不满足时,Codex 必须在同一游戏实现任务中调用审核的 `agc_tools` 生图或编辑工具,读取返回的资源身份与相对路径,把真实产物接入游戏源码,再构建并验证实际渲染。生成了素材但源码仍使用 emoji、CSS 形状或临时占位图替代策划要求的视觉元素,不能报告游戏完成。只有策划明确不需要视觉素材,或现有已登记素材完全满足需求时,才允许跳过生图;图片生成、处理、登记和接入不因用户没有重复输入“生图”而降级为可选建议。 + +## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈 + +DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / retryable / occurredAt / elapsedMs / publicText / recoveryHint / detailRef`,其中 `publicText` 是脱敏后的可行动摘要,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。 + +项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示 `publicText`,点击详情后按 `detailRef` 读取有界、脱敏的诊断,不直接展示私有 `detail`。 + +`turn/completed` 等待超时必须区分 `idle-timeout`、`hard-timeout`、`transport-closed`、`failed-turn`、`invalid-terminal` 和 `tool-error`;收到内置工具参数错误后必须结束当前工具调用并进入可行动终态,不能继续使用越界的试玩 `attempt` 或无限等待。试玩次数由客户端按当前 `clientTurnId` 持久化分配,模型不能自由递增;超过上限必须返回一次终态并停止回合。 + +游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 + +验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 From c7ba007463bfe0d08cdcb4d1a5a1deafafbf3a6e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:16:12 +0800 Subject: [PATCH 06/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E9=95=BF?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=89=B4=E6=9D=83=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 DirectProject 长回合平台会话保活与 401 分类 允许已登记普通平台图片通过运行时素材完成门禁 兼容预览 UUID 图片路径并补充技术方案说明 --- .../src-tauri/src/agent/direct_runtime.rs | 85 +++++++++++++++++-- .../src-tauri/src/agent/direct_tool_bridge.rs | 3 + .../src/agent/generation/canvas_generation.rs | 16 ++++ apps/ai-game-creator-shell/src/App.tsx | 18 ++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 ++ 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 64343f79f..5d358b640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2353,6 +2353,12 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .map(|asset| asset.local_path), ); } + // Ordinary platform images generated by `agc_generate_image` are valid + // runtime art even when the project does not contain the canonical + // art-spec/background/spritesheet package. The old check only admitted + // those canonical paths, so a game using registered images such as + // `assets/neon-mine.png` was forced through repeated repair turns forever. + available_paths.extend(direct_registered_taonier_runtime_image_paths(root)); available_paths.sort(); available_paths.dedup(); available_paths @@ -2361,6 +2367,34 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { + let Ok(manifest) = read_manifest_for_project(root) else { + return Vec::new(); + }; + manifest + .assets + .iter() + .filter(|asset| { + asset.media_type.starts_with("image/") + && asset.kind != "icon-spec" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && asset + .source + .generation_route + .as_deref() + .is_some_and(|route| route.starts_with("/api/external/v1/editor/")) + }) + .filter_map(|asset| { + let path = direct_normalized_project_asset_path(&asset.local_path)?; + let bytes = std::fs::read(root.join(&path)).ok()?; + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {path}")) + .ok()?; + validated.has_visible_pixels.then_some(path) + }) + .collect() +} + fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { !direct_game_sources_referenced_taonier_assets(root).is_empty() } @@ -2416,11 +2450,21 @@ fn direct_browser_evidence_needs_art_repair( let Some(evidence) = evidence else { return false; }; + let referenced_count = direct_game_sources_referenced_taonier_assets(root).len(); evidence.passed - && evidence - .viewport_results - .iter() - .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty()) + && evidence.viewport_results.iter().any(|viewport| { + let exact_matches = direct_rendered_taonier_assets_in_viewport(root, viewport); + if !exact_matches.is_empty() { + return false; + } + // The preview server rewrites local asset URLs to opaque UUID + // routes. When the browser probe cannot map those routes back to + // project-relative paths, use its count as a bounded observation: + // every referenced platform image must have a corresponding + // rendered local-image route in this viewport. + let rendered_count = direct_browser_rendered_image_paths(viewport).len(); + referenced_count == 0 || rendered_count < referenced_count + }) } /// The only output-shape proof the direct Runtime owns. It deliberately does @@ -3790,7 +3834,16 @@ fn render_direct_browser_acceptance_report( }; let assets = direct_rendered_taonier_assets_in_viewport(root, viewport); if assets.is_empty() { - format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + let rendered_count = direct_browser_rendered_image_paths(viewport).len(); + if rendered_count > 0 + && !direct_game_sources_referenced_taonier_assets(root).is_empty() + { + format!( + "{name}: 已观察到 {rendered_count} 个本地图片资源进入 Canvas/WebGL 渲染" + ) + } else { + format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + } } else { format!("{name}: {}", assets.join("、")) } @@ -7854,6 +7907,28 @@ mod tests { .is_some_and(|error| error.contains("未在源码中引用任何已登记的陶泥儿平台图片"))); } + #[test] + fn direct_completion_accepts_an_independent_registered_platform_image() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片") + .expect("init project"); + std::fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image"); + std::fs::write(root.path().join("game/index.html"), "").expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const mine = new Image(); mine.src = '/assets/neon-mine.png';", + ) + .expect("script"); + + assert_eq!( + direct_game_sources_referenced_taonier_assets(root.path()), + vec!["assets/neon-mine.png".to_string()] + ); + assert!(direct_game_output_completion_error(root.path()).is_none()); + } + #[test] fn direct_completion_accepts_a_registered_independent_slice_reference() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 12c6f8d27..059fd8481 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1914,6 +1914,9 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .await .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; if !status.is_success() { + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string()); + } return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); } let queue_state = external_editor_response_data(&payload).clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f9bbd95a8..693aa2dc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -868,6 +868,12 @@ pub(crate) async fn external_editor_json_request( /// entire response body (which may contain URLs, ids, paths or credentials). fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String { let detail = summarize_external_http_error_body(body); + if status == reqwest::StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => format!("authentication-required: {action}失败:HTTP 401:{detail}"), + None => format!("authentication-required: {action}失败:HTTP 401"), + }; + } match detail { Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()), None => format!("{action}失败:HTTP {}", status.as_u16()), @@ -7930,6 +7936,16 @@ mod canvas_generation_tests { assert!(!summary.contains("C:\\Users\\private"), "{summary}"); } + #[test] + fn external_http_401_is_classified_as_authentication_required() { + let error = + format_external_http_error("提交平台图片生成", reqwest::StatusCode::UNAUTHORIZED, ""); + assert_eq!( + error, + "authentication-required: 提交平台图片生成失败:HTTP 401" + ); + } + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream .set_nonblocking(false) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 723c0429c..fee3d987f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -286,6 +286,11 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +// Platform access tokens are short lived. DirectProject can spend several +// minutes in image generation, build and browser validation, so keep the +// client-owned native session current while a turn is running. The singleflight +// refresh in platformSession.ts coalesces this with any 401-triggered refresh. +const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; function isDirectCodexAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -1881,6 +1886,19 @@ export function App({ }; }, [directCodexProductRuntime]); + useEffect(() => { + if (!directCodexProductRuntime || !chatAgentBusy) { + return; + } + const timer = window.setInterval(() => { + void requestPlatformSessionRefresh().catch(() => { + // The active DirectProject turn will surface the original auth error; + // keepalive must not replace it with an unrelated background error. + }); + }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); + return () => window.clearInterval(timer); + }, [chatAgentBusy, directCodexProductRuntime]); + useEffect(() => { if (projectSupervisorOnly && !directCodexProductRuntime) { return; diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3f091c118..a232c7612 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-15 DirectProject 长回合平台会话保活 + +DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。 + +完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。 + ## 2026-09-12 已有项目打开响应性 DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。 From 7015f41a8e2694c1b7358ff4583bc5378978ecdc Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 22:19:35 +0800 Subject: [PATCH 07/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Skill=20?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 taonier-art-assets 内容指纹 递增 Skill pack manifest 版本 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 76de3bbc3..19833fb4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.14", + "version": "2026-08-26.15", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37" + "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec" }, { "name": "agc-web-game-development", From 2ef9238aa9b63e2756c9f4170901a12e82325c48 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 22:50:02 +0800 Subject: [PATCH 08/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=A4=87=E4=BB=BD=E7=8A=B6=E6=80=81=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 备份锁冲突时写入失败状态 JSON 发布脚本安全解析异步备份状态并保留异常文件 生产发布临时目录统一使用 ~/data/tmp --- scripts/database-backup-to-oss.mjs | 15 ++++++++++++ scripts/deploy/production-stdb-publish.sh | 30 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index c64e8657f..148267ac7 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -4517,6 +4517,21 @@ if ( for (const line of describeError(error)) { console.error(`[database-backup] ${line}`); } + // 即使初始化失败(例如已有进程持有锁),也保持 --result-file 可被机器读取。 + // 调用方可以检查错误载荷并跳过延后上传,避免对空的临时文件执行 JSON.parse。 + try { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.resultFile) { + atomicWriteJson(resolvePath(parsed.resultFile), { + uploadStatus: 'failed', + error: error instanceof Error ? error.message : String(error), + archivePath: '', + manifestPath: '', + }); + } + } catch { + // 参数解析或状态文件写入失败时,不能掩盖原始备份错误。 + } process.exit(1); }); } diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index f34492476..c102e45ec 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -519,7 +519,9 @@ prepare_async_backup() { restart_service_args+=(--restart-service-after genarrative-api.service) fi - ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)" + task_tmp_dir="${HOME}/data/tmp" + mkdir -p "${task_tmp_dir}" + ASYNC_BACKUP_STATUS_FILE="$(mktemp "${task_tmp_dir}/genarrative-stdb-backup-status.XXXXXX.json")" echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS" node -- "${ASYNC_BACKUP_SCRIPT}" \ --env-file /etc/genarrative/api-server.env \ @@ -536,14 +538,32 @@ start_async_backup_upload() { local node_binary="" local unit_name="" local unit_suffix="" + local backup_paths="" if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2 return 0 fi - ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" - ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" + # 备份进程可能因数据库锁冲突在写入状态文件前退出,或只留下空/截断文件。 + # 解析失败时保留状态文件并让调用方继续处理发布失败,不能再让 node JSON.parse + # 的堆栈噪声覆盖原始备份错误。 + if ! backup_paths="$(node -e ' + const fs = require("node:fs"); + const p = process.argv[1]; + let o; + try { + o = JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + process.exit(2); + } + process.stdout.write(`${o.archivePath || ""}\n${o.manifestPath || ""}`); + ' "${ASYNC_BACKUP_STATUS_FILE}" 2>/dev/null)"; then + echo "[production-stdb-publish] 警告:备份状态文件为空或不是有效 JSON,跳过异步上传并保留状态文件: ${ASYNC_BACKUP_STATUS_FILE}" >&2 + return 1 + fi + ASYNC_BACKUP_ARCHIVE="${backup_paths%%$'\n'*}" + ASYNC_BACKUP_MANIFEST="${backup_paths#*$'\n'}" if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2 return 0 @@ -727,7 +747,9 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2 exit 1 fi - PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)" + task_tmp_dir="${HOME}/data/tmp" + mkdir -p "${task_tmp_dir}" + PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/genarrative-stdb-publish.XXXXXX")" install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm" chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}" PUBLISH_ARGS=( From 390bfafed82c9abb5d6ed3d5e47956bb789e40d8 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 15 Sep 2026 23:20:06 +0800 Subject: [PATCH 09/49] Opt/design simplify (#377) Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/377 Co-authored-by: Linghong Co-committed-by: Linghong --- .../phase-context/overview-card.md | 4 +- .../src-tauri/design-agent/resources/SKILL.md | 146 +++++++++--------- .../exemplars/tdd-art-bible-SKILL.md | 2 +- .../resources/exemplars/tdd-data-SKILL.md | 2 +- .../resources/exemplars/tdd-tech-SKILL.md | 2 +- .../resources/skills/architecture.md | 13 +- .../design-agent/resources/skills/concept.md | 21 +-- .../design-agent/resources/skills/systems.md | 16 +- .../design-agent/resources/skills/tdd.md | 13 +- .../resources/skills/top_design.md | 30 ++-- .../resources/templates/architecture.md | 5 + .../resources/templates/concept-design.md | 8 +- .../resources/templates/tdd-art-bible.md | 11 +- .../resources/templates/tdd-data.md | 8 +- .../resources/templates/tdd-master.md | 16 +- .../resources/templates/tdd-tech.md | 10 +- .../resources/templates/top-design.md | 9 +- .../src-tauri/src/agent/design_runtime.rs | 63 +++++++- .../src-tauri/src/agent/direct_runtime.rs | 7 + 19 files changed, 247 insertions(+), 139 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/phase-context/overview-card.md b/apps/ai-game-creator-shell/src-tauri/design-agent/phase-context/overview-card.md index 0eb32b1e6..b1c0e4a56 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/phase-context/overview-card.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/phase-context/overview-card.md @@ -1,4 +1,4 @@ -概念阶段定稿时,还必须创建或更新 `project/速览卡.md`。Runtime 只检查该文件是否存在,不检查内容。请使用下面的固定结构,不要加入审批操作说明或独立的决定状态段落: +概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择字段,同类内容可以合并,项目不需要的字段可以省略,复杂项目可以增加必要字段。表格和列表中的示例行可按实际对象逐行扩展,不代表数量上限: # 速览卡:《游戏名》 @@ -20,7 +20,7 @@ ## 6. 核心循环 -## 7. 目标用户 +## 7. 目标用户与情境 - 核心用户: - 游戏偏好: - 单次游玩时长: diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md index aa24d28c9..8a3109cd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md @@ -2,7 +2,7 @@ 以下为总纲骨架;实际部署时拼接五份分册全文常驻(附录 A): -你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法变成完整可开工的策划产物树:五层文档(概念→顶层→架构→系统×N→技术文档)加速览卡投影——施工方只看技术文档就能做完游戏。【主轴】五层顺序推进:概念→顶层→架构→系统→技术文档;上层未定稿不开下层,定稿以用户检阅确认为准。用户参与度沿层递减:概念层事事确认,技术文档层靠知识与代决。【模板与样例】查看模板或样例时,应根据当前游戏的具体需求和用户实际要求决定产物的字段、章节和展开程度。模板与样例仅作为参考结构和写法示例,可按需要增加、合并或省略内容;不要为了复刻模板或样例而机械照抄其章节、字段、数量或篇幅。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——从回答中有意识地提炼调性锚(T 原则 3~7 条,每条必须能当 IF-THEN 用),写入概念层第 2 节。此后全项目一切判断先回调性锚级联。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线:概念≤3、顶层≤5,超线先回读调性锚。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编必带"基于系统文档@版本"。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后强制第二遍深化;每层对照量化验收线自查。【产物纪律】概念层一页纸不出现数值按键界面;顶层取舍表每行挂张力编号;架构职责表每行含"不负责→移交谁";技术文档数值全填文本全填资产全行登记——"纯看技术文档能做完游戏"是最终验收;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读调性锚。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 +你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法整理成与项目范围匹配、可开工的策划产物:五层文档(概念→顶层→架构→系统×N→技术文档)是可用的组织方式,不是每个项目都必须完整执行的固定流水线。【主轴】按项目规模和用户要求选择需要的层级;层级可以合并、裁剪或补充,上层未定稿时不得让下层替它拍板,定稿以用户检阅确认为准。用户参与度沿层递减:前期关注用户取舍,后期关注实现合同。【模板与样例】模板与样例提供参考结构和写法,产物的字段、章节、数量、篇幅和展开程度按当前游戏需求与用户要求决定。适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——调性原则的数量和形式按项目需要决定。此后全项目一切判断先回用户已确认的核心承诺和范围。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线按项目复杂度决定,不以固定条数或固定章节作为完成标准。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编按当前版本的施工需要决定,不为不存在的系统、数据、界面、素材或配置建立文档。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后按项目范围做必要的一致性检查;不以填满模板或扩展篇幅作为质量标准。【产物纪律】每层只写当前范围需要的内容;架构职责表在存在多个职责边界时明确不负责与移交;技术文档覆盖实际施工所需的系统、数据、界面和素材;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读核心承诺。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 - 部署:单 Agent——现 plan 根 Supervisor 与立项策划两个 Agent 合并为一个策划 Agent,全程单一连续上下文(主控六步职责并入系统提示词承载);project-planning.md 整文件替换为本骨架+附录 A 分册拼接(编译期打包路径不变),决策卡渲染与审批等运行时机制沿用 Runtime 代管。 @@ -43,11 +43,11 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 二、动笔前 1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。 没有 → 先问一个定调问题,禁止自问自答充当用户。 -2. 读例子_星露谷_概念设计.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_概念设计.md 了解内容组织方式, 然后往 模板_概念设计.md 里填。 3. 零参照时在文档头注明"零参照"。 -## 三、九节总览:写什么、为什么、怎么咬合 +## 三、概念设计的组织维度:写什么、为什么、怎么咬合 概念文档回答四个问题: **这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→ @@ -66,7 +66,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 | 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 | | 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 | | 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" | -| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 | +| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 按需记录给顶层的约束 | 收口并检查概念是否写散;把承诺转成对下的契约 | 回环呼应 1;把边界和交接约束传给下一层 | 咬合一图: @@ -96,7 +96,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 **定调记录**(全项目调性真源,此节定死): - 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。 - 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。 -- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 +- 调性锚 T 原则:按项目需要提炼并逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。 → 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。 **设计锚点(六项,争议时的仲裁原则,全部具名)** @@ -128,8 +128,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 7. 核心张力 - __ 有限,但 __。 - __ vs __(两端的代价各是什么)。 -→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的 - 种子,后面要逐条对应。 +→ 如果项目存在核心张力,保留的每条张力都应说明双方代价;没有形成有效张力时,不为了满足结构新增张力。这些是顶层取舍表的种子,后面按需对应。 ### 8. 边界与约束 - 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、 @@ -140,9 +139,9 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 9. 概念定稿(收口重锤) 这个游戏的核心不是 __,而是: > (一句话重述核心承诺) -交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。 +交给下一层的约束:按项目需要记录,顶层据此展开。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -174,7 +173,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:出现具体数值、按键、界面即删。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 ## A2 顶层设计分册(game-gdd-top-design) @@ -205,11 +204,11 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ## 二、动笔前 1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。 -2. 读例子_星露谷_顶层设计.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_顶层设计.md 了解内容组织方式, 往 模板_顶层设计.md 里填。 -3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。 +3. 把概念层已确认的核心张力作为输入;存在对应取舍时再挂上编号。 -## 三、十六节总览:写什么、为什么、怎么咬合 +## 三、顶层设计的组织维度:写什么、为什么、怎么咬合 顶层文档回答四个问题: **玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→ @@ -222,14 +221,14 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 |---|---|---|---|---| | 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 | | 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 | -| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 | +| 3 | 核心推动力 | 按项目实际存在的即时、阶段或长期推动力组织 | 玩家"什么时候被什么推着走"的推动结构 | 与实际节奏结构对应 | | 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 | -| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 | -| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 | +| 5 | 小循环 | 按项目实际存在的局内或短周期动词链组织 | 记录真正被玩到的循环 | 按实际循环层级互检 | +| 6 | 资源流与输入输出 | 按项目实际存在的资源流、输入输出和反馈组织 | 说明循环中的实际供给与结果 | 与实际循环环节对应 | | 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 | | 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 | | 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) | -| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 | +| 10 | 节奏结构 | 按项目实际存在的时间层级和情绪变化组织 | 说明玩法节奏如何变化 | 与实际推动力层级对应 | | 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 | | 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 | | 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 | @@ -275,24 +274,24 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ### 3. 核心推动力 - 动机主次:__。 - 即时推动 __;日程推动 __;季节推动 __;长期推动 __。 -→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。 +→ 只展开项目实际存在的时间层级;不存在的层级不设字段。 ### 4. 大循环 **__ → __ → __ → __ → 回到 __。**(附核心循环图) → 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。 -### 5. 小循环(具名动词链 ×3+) +### 5. 小循环(按项目实际数量) **__循环**:__ → __ → __ → __ → __。 -→ 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。 +→ 为保留的循环命名;动词链完整到可以直接照做。 ### 6. 资源流与输入输出 (资源流图:每种核心资源 来源 → 储存 → 消耗 三段全) -主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。 +主要输入 __;主要输出 __;按项目需要记录反馈层级。 → 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。 ### 7. 最小体验单位 __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 -单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。 +保留的玩家行动应有与玩法相称的可理解反馈;反馈形式和数量按项目决定。 ### 8. 核心活动流程(段落表) | 阶段 | 玩家行为 | 设计目的 | @@ -332,7 +331,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。 后续架构必须围绕 __ 拆系统;不得 __。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -366,7 +365,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 ## A3 系统架构分册(game-gdd-architecture) @@ -399,11 +398,11 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 ## 二、动笔前 1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束** 摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。 -2. 读例子_星露谷_系统架构.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_系统架构.md 了解内容组织方式, 往 模板_系统架构.md 里填。 3. 记住顶层的核心循环图——切完必须跑覆盖检查。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、架构设计的组织维度:写什么、为什么、怎么咬合 架构文档回答四个问题: **这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→ @@ -459,7 +458,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | -→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 +→ 对实际拆出的系统说明删除后的影响;无法形成独立职责的部分合并。 ### 3. 系统职责 | 系统 | 主要职责 | 不负责 → 移交谁 | @@ -562,15 +561,15 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 防返工价值最高的几行。 - 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。 - 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。 -- 所有系统同构:读者读熟一份就能读所有份。 +- 系统文档保持基本可读的一致性,但不要求所有系统使用相同章节;结构应服从系统类型和实际行为。 ## 二、动笔前 1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 的判定部分),读该文件夹 SKILL.md 与 模板.md。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 +3. 该文件夹标注"必读例子"的,先读例子全文了解对应系统的内容组织方式。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、系统文档的组织维度:写什么、为什么、怎么咬合 系统文档回答四个问题: **这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ @@ -585,7 +584,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 | 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 | | 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 | | 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 | -| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 | +| 8 | 反馈 | 关键结果何时、以何种方式反馈 | 让实际结果可理解 | 与本系统实际结果对应 | | 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 | | 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 | | 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 | @@ -599,12 +598,12 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 2 支撑体验:对应顶层目标第__条、调性原则第__条。 -3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。 -4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。 +3 进入与退出:按本系统实际存在的入口、退出和恢复路径记录。 +4 玩家行动:记录本系统实际存在的具名动词组;编排类写"安排"动词,活动类写"操作"动词。 5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。 6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。 7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。 -8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 +8 反馈:记录本系统关键结果的可理解反馈;存在失败时说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 @@ -642,7 +641,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD), 不替别的系统定规则。 -3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。 +3. 不凑数:写不出"删了塌什么"的系统直接删除;章节对项目有意义但信息不足时,记录已确定内容与待补问题。 ## A5 技术文档分册(game-tdd) @@ -661,7 +660,7 @@ description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿 ## 〇、TDD 的完成判据(总纲) -**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。** +**TDD 是当前版本的施工合同:施工方只看 TDD,应能完成本项目实际范围内的实现。** GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。 检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、 每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口, @@ -683,8 +682,7 @@ GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施 TDD 不擅自换运行时。 - **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统, 其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。 -- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测 - 验证过,照抄。 +- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容。 - **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都 不做"做完一大批才发现不对"的事。 @@ -773,11 +771,11 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 二、动笔前 1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。 没有 → 先问一个定调问题,禁止自问自答充当用户。 -2. 读例子_星露谷_概念设计.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_概念设计.md 了解内容组织方式, 然后往 模板_概念设计.md 里填。 3. 零参照时在文档头注明"零参照"。 -## 三、九节总览:写什么、为什么、怎么咬合 +## 三、概念设计的组织维度:写什么、为什么、怎么咬合 概念文档回答四个问题: **这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→ @@ -796,7 +794,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 | 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 | | 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 | | 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" | -| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 | +| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 按需记录给顶层的约束 | 收口并检查概念是否写散;把承诺转成对下的契约 | 回环呼应 1;把边界和交接约束传给下一层 | 咬合一图: @@ -826,7 +824,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 **定调记录**(全项目调性真源,此节定死): - 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。 - 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。 -- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 +- 调性锚 T 原则:按项目需要提炼并逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。 → 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。 **设计锚点(六项,争议时的仲裁原则,全部具名)** @@ -858,8 +856,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 7. 核心张力 - __ 有限,但 __。 - __ vs __(两端的代价各是什么)。 -→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的 - 种子,后面要逐条对应。 +→ 如果项目存在核心张力,保留的每条张力都应说明双方代价;没有形成有效张力时,不为了满足结构新增张力。这些是顶层取舍表的种子,后面按需对应。 ### 8. 边界与约束 - 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、 @@ -870,9 +867,9 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 9. 概念定稿(收口重锤) 这个游戏的核心不是 __,而是: > (一句话重述核心承诺) -交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。 +交给下一层的约束:按项目需要记录,顶层据此展开。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -904,7 +901,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:出现具体数值、按键、界面即删。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 @@ -937,11 +934,11 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ## 二、动笔前 1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。 -2. 读例子_星露谷_顶层设计.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_顶层设计.md 了解内容组织方式, 往 模板_顶层设计.md 里填。 -3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。 +3. 把概念层已确认的核心张力作为输入;存在对应取舍时再挂上编号。 -## 三、十六节总览:写什么、为什么、怎么咬合 +## 三、顶层设计的组织维度:写什么、为什么、怎么咬合 顶层文档回答四个问题: **玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→ @@ -954,14 +951,14 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 |---|---|---|---|---| | 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 | | 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 | -| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 | +| 3 | 核心推动力 | 按项目实际存在的即时、阶段或长期推动力组织 | 玩家"什么时候被什么推着走"的推动结构 | 与实际节奏结构对应 | | 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 | -| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 | -| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 | +| 5 | 小循环 | 按项目实际存在的局内或短周期动词链组织 | 记录真正被玩到的循环 | 按实际循环层级互检 | +| 6 | 资源流与输入输出 | 按项目实际存在的资源流、输入输出和反馈组织 | 说明循环中的实际供给与结果 | 与实际循环环节对应 | | 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 | | 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 | | 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) | -| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 | +| 10 | 节奏结构 | 按项目实际存在的时间层级和情绪变化组织 | 说明玩法节奏如何变化 | 与实际推动力层级对应 | | 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 | | 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 | | 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 | @@ -1007,24 +1004,24 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ### 3. 核心推动力 - 动机主次:__。 - 即时推动 __;日程推动 __;季节推动 __;长期推动 __。 -→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。 +→ 只展开项目实际存在的时间层级;不存在的层级不设字段。 ### 4. 大循环 **__ → __ → __ → __ → 回到 __。**(附核心循环图) → 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。 -### 5. 小循环(具名动词链 ×3+) +### 5. 小循环(按项目实际数量) **__循环**:__ → __ → __ → __ → __。 -→ 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。 +→ 为保留的循环命名;动词链完整到可以直接照做。 ### 6. 资源流与输入输出 (资源流图:每种核心资源 来源 → 储存 → 消耗 三段全) -主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。 +主要输入 __;主要输出 __;按项目需要记录反馈层级。 → 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。 ### 7. 最小体验单位 __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 -单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。 +保留的玩家行动应有与玩法相称的可理解反馈;反馈形式和数量按项目决定。 ### 8. 核心活动流程(段落表) | 阶段 | 玩家行为 | 设计目的 | @@ -1064,7 +1061,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。 后续架构必须围绕 __ 拆系统;不得 __。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -1098,7 +1095,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 @@ -1133,11 +1130,11 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 ## 二、动笔前 1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束** 摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。 -2. 读例子_星露谷_系统架构.md 做质量锚(模仿密度,不抄内容), +2. 读取例子_星露谷_系统架构.md 了解内容组织方式, 往 模板_系统架构.md 里填。 3. 记住顶层的核心循环图——切完必须跑覆盖检查。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、架构设计的组织维度:写什么、为什么、怎么咬合 架构文档回答四个问题: **这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→ @@ -1193,7 +1190,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | -→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 +→ 对实际拆出的系统说明删除后的影响;无法形成独立职责的部分合并。 ### 3. 系统职责 | 系统 | 主要职责 | 不负责 → 移交谁 | @@ -1298,15 +1295,15 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 防返工价值最高的几行。 - 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。 - 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。 -- 所有系统同构:读者读熟一份就能读所有份。 +- 系统文档保持基本可读的一致性,但不要求所有系统使用相同章节;结构应服从系统类型和实际行为。 ## 二、动笔前 1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 的判定部分),读该文件夹 SKILL.md 与 模板.md。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 +3. 该文件夹标注"必读例子"的,先读例子全文了解对应系统的内容组织方式。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、系统文档的组织维度:写什么、为什么、怎么咬合 系统文档回答四个问题: **这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ @@ -1321,7 +1318,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 | 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 | | 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 | | 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 | -| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 | +| 8 | 反馈 | 关键结果何时、以何种方式反馈 | 让实际结果可理解 | 与本系统实际结果对应 | | 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 | | 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 | | 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 | @@ -1335,12 +1332,12 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 2 支撑体验:对应顶层目标第__条、调性原则第__条。 -3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。 -4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。 +3 进入与退出:按本系统实际存在的入口、退出和恢复路径记录。 +4 玩家行动:记录本系统实际存在的具名动词组;编排类写"安排"动词,活动类写"操作"动词。 5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。 6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。 7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。 -8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 +8 反馈:记录本系统关键结果的可理解反馈;存在失败时说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 @@ -1378,7 +1375,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD), 不替别的系统定规则。 -3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。 +3. 不凑数:写不出"删了塌什么"的系统直接删除;章节对项目有意义但信息不足时,记录已确定内容与待补问题。 @@ -1399,7 +1396,7 @@ description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿 ## 〇、TDD 的完成判据(总纲) -**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。** +**TDD 是当前版本的施工合同:施工方只看 TDD,应能完成本项目实际范围内的实现。** GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。 检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、 每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口, @@ -1421,8 +1418,7 @@ GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施 TDD 不擅自换运行时。 - **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统, 其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。 -- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测 - 验证过,照抄。 +- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容。 - **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都 不做"做完一大批才发现不对"的事。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-art-bible-SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-art-bible-SKILL.md index 977ae5eb2..bf75403ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-art-bible-SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-art-bible-SKILL.md @@ -33,7 +33,7 @@ description: 写"美术圣经"(美术侧)分册时使用。与总纲(技 对象(资产总清单的范围)、物品表(item_id 绑定依据,数据侧已定)、 画风 skill(全局画风库可引用)。 2. 本件在数据侧表结构定稿后开写(素材清单引用 item_id)。 -3. 读金样 exemplars/stardew-tdd-art-bible.md——契约表与资产状态表的登记密度以它为准(同层只读一次)。 +3. 读取金样 exemplars/stardew-tdd-art-bible.md 了解契约表与资产状态表包含的信息类型(同层只读一次)。 ## 三、怎么写(模板即流程,按节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-data-SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-data-SKILL.md index 239a8fe3a..96d5031e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-data-SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-data-SKILL.md @@ -34,7 +34,7 @@ description: 写"数据与配表"(数据侧)分册时使用。与总纲( (架构层的定性基准,在本件落成前 N 日验算)。 2. 先读两份提取件:字段字典全套规则与验收模板已在那里成文,本件是 项目实例化,不是重新发明。 -3. 读金样 exemplars/stardew-tdd-data.md——总清单规模、验算表与验收结论的写法以它为准(同层只读一次)。 +3. 读取金样 exemplars/stardew-tdd-data.md 了解数据清单、验算表与验收结论包含的信息类型(同层只读一次)。 ## 三、怎么写(模板即流程,按节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-tech-SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-tech-SKILL.md index c6dace78d..0348460b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-tech-SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/exemplars/tdd-tech-SKILL.md @@ -28,7 +28,7 @@ description: 写"技术实现"(程序侧)分册时使用。与总纲(技 1. 输入齐了吗:架构层系统范围表+P0 清单(拆模块依据)、数据侧表结构契约 (加载与校验要引用)、skill 选型卡(实现类需求先查卡,不自造轮子)。 2. 读总纲判断立场;本件在数据侧表结构定稿后开写。 -3. 读金样 exemplars/stardew-tdd-tech.md——各节的填充密度与"实证参照"写法以它为准(同层只读一次)。 +3. 读取金样 exemplars/stardew-tdd-tech.md 了解技术实现文档包含的信息类型(同层只读一次)。 ## 三、怎么写(模板即流程,按节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md index 41dc962ec..e5f12501d 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md @@ -13,10 +13,13 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 > 本文件是系统架构层唯一承载写作流程的教学件。 > 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +## 〇、结构适配原则 + +本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和顶层设计判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用架构。 + ## 一、这一层的判断立场 你是架构师,切系统的刀在你手里。在这个层里你相信: -- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量——每个系统必须能 - 一句话答出"删了它,什么塌"(P0 原因)。 +- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量。只有确实需要独立职责、状态或数据边界的部分才拆成系统;每个实际拆出的系统应能说明删除后的影响。 - **数据所有权唯一**:同一事实只由一个系统维护,其他系统只引用稳定 ID, 不复制主数据。两个系统管同一件事 = 架构事故。 - **依赖无环**是硬要求;信息呈现层只读状态、只经行动入口写入。 @@ -28,11 +31,11 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 ## 二、动笔前 1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束** 摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。 -2. 读 exemplars/stardew-architecture.md 做质量锚(模仿密度,不抄内容), +2. 读取 exemplars/stardew-architecture.md 了解内容组织方式, 然后往 templates/architecture.md 里填。 3. 记住顶层的核心循环图——切完必须跑覆盖检查。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、架构设计的组织维度:写什么、为什么、怎么咬合 架构文档回答四个问题: **这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→ @@ -88,7 +91,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | -→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 +→ 对实际拆出的系统说明删除后的影响;无法形成独立职责的部分合并,不为满足数量新增系统。 ### 3. 系统职责 | 系统 | 主要职责 | 不负责 → 移交谁 | diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md index 1d8f73496..df7957403 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md @@ -13,6 +13,10 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 > 本文件是概念层唯一承载写作流程的教学件。 > 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +## 〇、结构适配原则 + +本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和上层已定范围判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。 + ## 一、这一层的判断立场 你是资深游戏策划,看过上千份概念案,清楚绝大多数死在"什么都说、什么都不尖"。 在这个层里你相信: @@ -27,11 +31,11 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 二、动笔前 1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。 没有 → 先问一个定调问题,禁止自问自答充当用户。 -2. 读 exemplars/stardew-concept.md 做质量锚(模仿密度,不抄内容), +2. 读取 exemplars/stardew-concept.md 了解内容组织方式, 然后往 templates/concept-design.md 里填。 3. 零参照时在文档头注明"零参照"。 -## 三、九节总览:写什么、为什么、怎么咬合 +## 三、概念设计的组织维度:写什么、为什么、怎么咬合 概念文档回答四个问题: **这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→ @@ -50,7 +54,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 | 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 | | 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 | | 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" | -| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 | +| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 按需记录给顶层的约束 | 收口并检查概念是否写散;把承诺转成对下的契约 | 回环呼应 1;把边界和交接约束传给下一层 | 咬合一图: @@ -80,7 +84,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 **定调记录**(全项目调性真源,此节定死): - 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。 - 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。 -- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 +- 调性锚 T 原则:按项目需要提炼并逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。 → 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。 **设计锚点(六项,争议时的仲裁原则,全部具名)** @@ -112,8 +116,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 7. 核心张力 - __ 有限,但 __。 - __ vs __(两端的代价各是什么)。 -→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的 - 种子,后面要逐条对应。 +→ 如果项目存在核心张力,保留的每条张力都应说明双方代价;没有形成有效张力时,不为了满足结构新增张力。这些是顶层取舍表的种子,后面按需对应。 ### 8. 边界与约束 - 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、 @@ -124,9 +127,9 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ### 9. 概念定稿(收口重锤) 这个游戏的核心不是 __,而是: > (一句话重述核心承诺) -交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。 +交给下一层的约束:按项目需要记录,顶层据此展开。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -158,4 +161,4 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:出现具体数值、按键、界面即删。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md index 50c44335c..72be4ef7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md @@ -12,6 +12,10 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 > 本文件是系统文档层的总纲;各系统的专属写法在 `modules/system-types/` 下对应目录的 `SKILL.md`, 专属模板在 `modules/system-types/` 对应目录的 `模板.md`。通用纪律不在各系统 skill 里重复。 +## 〇、结构适配原则 + +本分册的章节、字段和数量是参考结构,不是固定清单。先根据系统类型、实际复杂度、用户要求和架构职责判断适用项:适用项写入,同类项可合并,若某项对本系统没意义则省略;复杂系统可以拆分补充,简单系统可以压缩为最小可执行规格。 + ## 一、这一层的判断立场 你是写单个系统的策划。在这个层里你相信: - 系统文档是**执行层**:刀已经在架构层切好——服从系统地图编号、职责表 @@ -20,7 +24,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 防返工价值最高的几行。 - 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。 - 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。 -- 所有系统同构:读者读熟一份就能读所有份。 +- 系统文档保持基本可读的一致性,但不要求所有系统使用相同章节;结构应服从系统类型和实际行为。 ## 二、动笔前 1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 @@ -43,7 +47,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 | 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 | | 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 | | 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 | -| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 | +| 8 | 反馈 | 关键结果何时、以何种方式反馈 | 让实际结果可理解 | 与本系统实际结果对应 | | 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 | | 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 | | 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 | @@ -57,12 +61,12 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 2 支撑体验:对应顶层目标第__条、调性原则第__条。 -3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。 -4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。 +3 进入与退出:按本系统实际存在的入口、退出和恢复路径记录。 +4 玩家行动:记录本系统实际存在的具名动词组;编排类写"安排"动词,活动类写"操作"动词。 5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。 6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。 7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。 -8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 +8 反馈:记录本系统关键结果的可理解反馈;存在失败时说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 @@ -100,4 +104,4 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD), 不替别的系统定规则。 -3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。 +3. 不凑数:写不出"删了塌什么"的系统直接删除;章节对项目有意义但信息不足时,记录已确定内容与待补问题。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md index b2b6e6c50..b649eb248 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md @@ -12,12 +12,16 @@ description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿 > 本文件是 TDD 层唯一承载写作流程的教学件;各分册 SKILL 与模板配套使用。 > 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +## 〇、结构适配原则 + +本分册的文档件、章节、字段和数量是参考结构,不是固定清单。先根据当前版本的实现目标、游戏规模、运行时和用户要求判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以合并为最小施工合同。 + ## 〇、TDD 的完成判据(总纲) -**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。** +**TDD 是当前版本的施工合同:施工方只看 TDD,应能完成本项目实际范围内的实现。** GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。 -检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、 -每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口, +检验方式=按项目范围检查施工所需信息是否齐全:实际存在的系统怎么行为、 +实际使用的表和配置怎么读取、实际存在的界面怎么走、实际需要的素材什么规格。答不出的项就是缺口, 缺口回 GDD 同步后**收编**进 TDD(带版本锁)。收编是构建期快照:GDD 定稿 变更 → 触发对应收编节重同步(与 fast_gdd 投影同一机制,方向相反)。 @@ -36,8 +40,7 @@ GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施 TDD 不擅自换运行时。 - **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统, 其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。 -- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测 - 验证过,照抄。 +- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容。 - **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都 不做"做完一大批才发现不对"的事。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md index 0eeba8577..9fec11e4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md @@ -13,6 +13,10 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 > 本文件是顶层设计唯一承载写作流程的教学件。 > 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +## 〇、结构适配原则 + +本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和概念层定稿判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。 + ## 一、这一层的判断立场 你是资深游戏策划,正在写全 GDD 最重要的一份文档——概念说"凭什么成立", 顶层说"好玩在哪"。核心循环无趣,后面写再多系统也救不回来。在这个层里你相信: @@ -26,11 +30,11 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ## 二、动笔前 1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。 -2. 读 exemplars/stardew-top-design.md 做质量锚(模仿密度,不抄内容), +2. 读取 exemplars/stardew-top-design.md 了解内容组织方式, 然后往 templates/top-design.md 里填。 -3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。 +3. 把概念层已确认的核心张力作为输入;存在对应取舍时再挂上编号。 -## 三、十六节总览:写什么、为什么、怎么咬合 +## 三、顶层设计的组织维度:写什么、为什么、怎么咬合 顶层文档回答四个问题: **玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→ @@ -43,14 +47,14 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 |---|---|---|---|---| | 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 | | 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 | -| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 | +| 3 | 核心推动力 | 按项目实际存在的即时、阶段或长期推动力组织 | 玩家"什么时候被什么推着走"的推动结构 | 与实际节奏结构对应 | | 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 | -| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 | -| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 | +| 5 | 小循环 | 按项目实际存在的局内或短周期动词链组织 | 记录真正被玩到的循环 | 按实际循环层级互检 | +| 6 | 资源流与输入输出 | 按项目实际存在的资源流、输入输出和反馈组织 | 说明循环中的实际供给与结果 | 与实际循环环节对应 | | 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 | | 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 | | 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) | -| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 | +| 10 | 节奏结构 | 按项目实际存在的时间层级和情绪变化组织 | 说明玩法节奏如何变化 | 与实际推动力层级对应 | | 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 | | 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 | | 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 | @@ -96,24 +100,24 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 ### 3. 核心推动力 - 动机主次:__。 - 即时推动 __;日程推动 __;季节推动 __;长期推动 __。 -→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。 +→ 只展开项目实际存在的时间层级;不存在的层级不设字段。 ### 4. 大循环 **__ → __ → __ → __ → 回到 __。**(附核心循环图) → 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。 -### 5. 小循环(具名动词链 ×3+) +### 5. 小循环(按项目实际数量) **__循环**:__ → __ → __ → __ → __。 → 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。 ### 6. 资源流与输入输出 (资源流图:每种核心资源 来源 → 储存 → 消耗 三段全) -主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。 +主要输入 __;主要输出 __;按项目需要记录反馈层级。 → 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。 ### 7. 最小体验单位 __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 -单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。 +保留的玩家行动应有与玩法相称的可理解反馈;反馈形式和数量按项目决定。 ### 8. 核心活动流程(段落表) | 阶段 | 玩家行为 | 设计目的 | @@ -153,7 +157,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。 后续架构必须围绕 __ 拆系统;不得 __。 -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 +若某节对本项目没意义,直接省略。 ## 五、分析文档(全局一份,按层分节) @@ -187,4 +191,4 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。 ## 七、红线(只有三条) 1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 +3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/architecture.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/architecture.md index 516a740bb..e6d8d5e85 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/architecture.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/architecture.md @@ -1,5 +1,7 @@ ### C1 模板_系统架构.md(→ templates/architecture.md) +本模板是参考结构,不是固定清单。只有需要独立职责、状态或数据边界的部分才拆成系统;简单项目可以合并系统和章节,复杂项目可以增加必要的系统与校验。表格中的示例行可按实际系统、风险和问题扩展,不代表数量上限。 + # 系统架构:《游戏名》 ## 架构定位与目标 @@ -18,6 +20,7 @@ |---|---|---|---| | S01 | __ | __ | P0 | | S02 | __ | __ | | +(以上为示例,可按实际系统删减或扩充。) 支撑层(不拥有核心规则):__。 @@ -105,6 +108,8 @@ flowchart LR | 风险 | 校验方式 | |---|---| | __ | __ | +(按实际风险逐行补充。) ## 开放的结构问题 - __ +(按实际问题逐条补充。) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/concept-design.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/concept-design.md index 229eccd32..36fc4dc79 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/concept-design.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/concept-design.md @@ -1,5 +1,7 @@ ### C1 模板_概念设计.md(→ templates/concept-design.md) +本模板是参考结构,不是固定清单。填写前按项目类型、规模和用户要求筛选章节与字段;同类内容可合并,若某节对项目没有实际意义则删除,复杂项目可增加必要内容。表格和列表中的示例项可按实际内容扩展,不代表数量上限。 + # 概念设计:《游戏名》 ## 一句话概念 @@ -10,14 +12,14 @@ ### 定调记录(全项目调性真源,级联决策的依据库) - 参照选择:以《__》为主(__, 学 __);不参考 __。 - 调性滑杆:压力感 __ / 战斗比重 __ / 管理深度 __ / 叙事比重 __ / 节奏 __。 -- 调性锚(T 原则,逐条具名,下游每个开放问题先来这里级联): - T1 __;T2 __;T3 __;T4 __;T5 __。 +- 调性锚(按项目需要逐条具名,下游开放问题按需从这里级联): + T__ __。 ### 设计锚点(六仲裁位) - 核心幻想:__。 玩家念头:"__" - 目标体验:__。 -- 玩家动机:短期 __;长期 __。 +- 玩家动机(按项目实际存在的时间尺度填写):__。 - 核心循环:__ → __ → __ → __ → 回到 __。 - 跑偏风险:__。 - 非目标:__(详见《不是什么》)。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-art-bible.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-art-bible.md index 510ad2608..891dfbf16 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-art-bible.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-art-bible.md @@ -1,5 +1,7 @@ ### C3 02_美术圣经/模板.md(→ templates/tdd-art-bible.md) +本模板是美术实施的参考结构。按项目实际需要选择角色、场景、UI、动画和素材契约;没有对应资产类型时删除相应章节,复杂项目可增加必要的视觉规则。表格和资产条目可按实际内容扩展,不代表数量上限。 + # 美术圣经:《游戏名》 > 状态:{drafting / reviewed / frozen} | 定调锚:概念层@v{N} 第 2 节 | style_id:`__` @@ -10,7 +12,7 @@ __(一段话:从定调记录翻译的视觉气质;参考图位 __ 张) ## 视觉锚 -- 关键词:__(3~5 个)。 +- 关键词:__(按项目需要)。 - 禁用关键词:__。 - 色板:主色 __ / 辅色 __ / 点缀 __(配比 __);昼夜·天气·季节表现 __。 - 形状语言:__。 @@ -40,6 +42,7 @@ __(承 UI 系统文档的界面清单;视觉语言与信息分层对齐) | 素材 | 规格(尺寸/帧数/方向数) | 命名规则 | atlas 格式 | 验收 | 绑定 | |---|---|---|---|---|---| | __ | __ | __ | __ | __ | `item_ __` / 豁免:__ | +(以上为示例,可按实际素材删减或扩充。) - 绘制工艺:__(用陶泥儿 MCP 的路径与参数;封装流程)。 - 豁免类型仅限:程序化生成 / UI 文本 / 本期不需要。 @@ -49,17 +52,19 @@ __(承 UI 系统文档的界面清单;视觉语言与信息分层对齐) | asset_id | 规格 | 绑定 | 状态 | 验收记录 | contract_version | |---|---|---|---|---|---| | __ | __ | `item_ __` / 豁免 | 缺失/草稿/已交付/已验收/已接入 | 技术过/视觉过 @__ | __ | +(以上为示例,可按实际资产删减或扩充。) - 状态单向流转:缺失 → 草稿 → 已交付 → 已验收 → 已接入;驳回退回草稿并记原因。 - 验收两维:技术(尺寸/透明/帧数/命名)+ 视觉(对照视觉锚);两维都过才进"已验收"。 -- 每个 gameplay 可见对象必有一行,或显式豁免——没有第三种状态。 +- 需要登记的 gameplay 可见对象有一行;不需要资产登记的对象不建立空记录。 - 程序接入后填消费点(哪个模块加载、事件映射),`contract_version` 变更须重验收。 ## 量产流程与验证 1. 概念候选 __ 张 → 2. 人选方向 → 3. 锚点图 __ 张 → 4. 锁圣经 → 5. 写契约 → 6. 小批 __ 张 → 7. 技术检查(__)→ 8. 接入程序 → -9. 运行时截图验收(桌面/移动双视口下 __ 可辨)→ 10. 扩产。 +9. 运行时验收(按项目支持的平台)→ 10. 扩产。 +(以上为示例,可按实际流程删减或扩充。) ## 开放问题回执 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-data.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-data.md index f7eb05b2a..244e8063b 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-data.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-data.md @@ -1,5 +1,7 @@ ### C3 03_数据与配表/模板.md(→ templates/tdd-data.md) +本模板是数据与配表的参考结构。只有项目实际存在配置、枚举、关系或条件数据时才建立对应表和校验;简单项目可以直接写配置约定,复杂项目再拆分表结构与验算流程。表格中的示例行可按实际数据、字段和验算项扩展,不代表数量上限。 + # 数据与配表:《游戏名》 > 状态:{structuring / filling / accepted} | 基于:各系统交接节汇总 | 验收:check@{id} 最新结论 __ @@ -9,6 +11,7 @@ | 表格组 | 建议表名 | 主要维护系统 | |---|---|---| | __ | __ | __ | +(以上为示例,可按实际数据表删减或扩充。) (表格拆分是生产组织方式,不改变主数据归属。) @@ -26,7 +29,7 @@ |---|---|---|---|---|---|---| | __ | date_day / progress_flag / skill_level / schedule_open / quest_completed / __ | __ | __ | __ | active | __ | -(复杂条件拆条件组+条件行;全项目只此一个条件入口,程序实现一次 `check(condition_id)`。) +(存在复杂条件时再拆条件组与条件行;没有条件系统时删除本节。) ## 工作簿组织与建表顺序 @@ -36,6 +39,7 @@ 建表顺序:①物品表(公共 item_id)→ ②__ → ③__ → ④__ → ⑤__ → ⑥__ → ⑦__ → ⑧__。 每完成一组查三件事:引用 ID 存在 / 条件有负责系统 / 同一数值只有一个系统维护。 +(以上为示例,可按实际表结构删减或扩充。) ## 表格-程序契约 @@ -54,12 +58,14 @@ | 表 | 字段 | 默认值 | 依据 | 推翻条件 | |---|---|---|---|---| | __ | __ | __ | T__ / 台账 id | __ | +(以上为示例,可按实际验算字段删减或扩充。) - 前五日闭环验算: | 日期 | 主目标 | 关键行动 | 主要成本 | 主要获得 | 结果 | |---|---|---|---|---|---| | 第 1 日 | __ | __ | __ | __ | __ | +(以上为示例,可按实际循环或阶段删减或扩充。) - 收益链校验:`__ → __ → __ → __ → __`(逐环引 ID)。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-master.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-master.md index cfae209d9..b8ecd9049 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-master.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-master.md @@ -1,5 +1,7 @@ ### C3 模板_TDD总册.md(→ templates/tdd-master.md) +本模板是 TDD 总册的参考结构。只建立当前项目实际需要的技术、美术、数据和索引内容;没有对应方向时不创建空分册,复杂项目可以增加施工所需的分册。表格中的示例行可按实际分册、问题和验收项扩展,不代表数量上限。 + # TDD 总册:《游戏名》 > 本册是 TDD 层的封面与索引:正文在三件分册(01 技术实现 / 02 美术圣经 / 03 数据与配表), @@ -7,19 +9,19 @@ ## 自足性检查(TDD 的完成判据) -> 标准:一个施工 agent 只看 TDD,能做完完整游戏。逐项模拟它必问的问题, +> 标准:施工方只看当前 TDD,能完成项目实际范围内的实现。逐项检查当前项目真正需要的问题, > 答得出=过;答不出=缺口(列 GDD 来源与同步动作)。 | # | 施工 agent 的问题 | 答案在哪 | 状态 | |---|---|---|---| -| 1 | 每个系统怎么行为(规则/行动/反馈)? | 01 收编章(@v{N}) | __ | -| 2 | 每张表有多少行内容、文本全填了吗? | 03 全量填充+完成度验收 | __ | -| 3 | 每个界面长什么样、怎么走? | 01 UI 交互规格 | __ | -| 4 | 每份素材什么规格、谁验收过? | 02 资产状态表(全行非缺失) | __ | +| 1 | 实际存在的系统怎么行为? | 01 收编章(@v{N}) | __ | +| 2 | 实际使用的表和配置是否可施工? | 03 数据与配表 | __ | +| 3 | 实际存在的界面怎么走? | 01 UI 交互规格 | __ | +| 4 | 实际需要的素材什么规格? | 02 资产状态表 | __ | | 5 | 代码怎么组织、跑在哪? | 01 代码组织+能力边界 | __ | | 6 | 怎么算做完了(判据)? | 01 里程碑+各件验收 | __ | -全部为"过"时,TDD 进入 frozen——构建可以完全脱离 GDD 进行。 +当前项目所需检查全部为"过"时,TDD 进入 frozen——构建可以在本版本范围内脱离 GDD 进行。 ## 三件状态 @@ -54,7 +56,7 @@ | 件 | 最近验收 | blocker | 结论 | |---|---|---|---| -| 01 | __(构建+双视口验证 @__) | __ | __ | +| 01 | __(按项目平台验证 @__) | __ | __ | | 02 | __(技术+视觉两维 @__) | __ | __ | | 03 | __(七查 @check_id) | __ | __ | diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-tech.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-tech.md index 42cc17b24..e8fb80ba8 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-tech.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/tdd-tech.md @@ -1,9 +1,11 @@ ### C3 01_技术实现/模板.md(→ templates/tdd-tech.md) +本模板是技术实现的参考结构。按当前运行时、系统复杂度和用户要求选择章节;没有对应系统、界面、输入、音频或存档需求时,删除相应内容,复杂项目可增加施工所需章节。表格和系统条目可按实际实现范围扩展,不代表数量上限。 + # 技术实现:《游戏名》 > 状态:{drafting / reviewed / frozen} | 基于 GDD:架构层@v{N} | 数据侧契约:data/contracts@v{M} -> **目标运行时:{HTML / Unity / Godot / Cocos}(由 GDD 平台事实锁定)** | 预览:{HTML=双视口浏览器 / 引擎=陶泥儿驱动弹窗} | 导出:{HTML=自包含 / 引擎=陶泥儿驱动 CLI} +> **目标运行时:{HTML / Unity / Godot / Cocos}(由 GDD 平台事实锁定)** | 预览:{按项目平台验证 / 引擎=陶泥儿驱动弹窗} | 导出:{HTML=自包含 / 引擎=陶泥儿驱动 CLI} ## 系统行为规格(收编章) @@ -28,7 +30,7 @@ ## 技术目标与平台事实 -- 平台事实(注入,禁改):自包含 Web · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地 HTTP 预览。 +- 平台事实(由 GDD 平台事实锁定):__。 - 技术目标:__(可测量,如"首屏可玩 ≤ __ 秒")。 ## 技术风险 @@ -39,7 +41,7 @@ ## 运行时能力边界 -| 能力(P0 七件) | 落位(按所选运行时) | 状态(原生/自封装/受限) | 说明 | +| 能力(按项目实际使用的能力填写) | 落位(按所选运行时) | 状态(原生/自封装/受限) | 说明 | |---|---|---|---| | 瓦片地图渲染 | __ | __ | __ | | 寻路 | __ | __ | __ | @@ -85,7 +87,7 @@ ## 构建与验证 - 构建:__(命令/流程)。 -- 验证分级:自动__(跑什么、看什么输出为过);半自动__(双视口浏览器验证步骤);手测__(谁试玩、观察什么)。 +- 验证分级:自动__(跑什么、看什么输出为过);半自动__(按项目平台验证步骤);手测__(谁试玩、观察什么)。 ## 版本里程碑 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/top-design.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/top-design.md index a343332ca..0d25e1887 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/top-design.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/templates/top-design.md @@ -1,5 +1,7 @@ ### C1 模板_顶层设计.md(→ templates/top-design.md) +本模板是参考结构,不是固定清单。填写前按项目实际存在的循环、资源、时间层级和用户要求筛选章节;同类内容可合并,若某项不存在则删除,复杂项目可增加必要内容。表格、列表和循环示例可按实际内容扩展,不代表数量上限。 + # 顶层设计:《游戏名》 ## 顶层定位与规模锚点 @@ -43,6 +45,7 @@ __ → __ → __ → __ → __。 ### __循环 __ → __ → __ → __。 +(以上为示例,可按实际循环删减或扩充。) ## 资源流与输入输出 @@ -58,13 +61,14 @@ flowchart LR ## 最小体验单位 __。 -单个行动必须至少提供一种清晰反馈:__。 +保留的玩家行动应有与玩法相称的可理解反馈:__。 ## 核心活动流程 | 阶段 | 玩家行为 | 设计目的 | |---|---|---| | __ | __ | __ | +(按实际阶段逐行补充。) ## 取舍表 @@ -77,6 +81,7 @@ __。 - 周内节奏:__。 - 季节/章节节奏:__。 - 长期节奏:__。 +(以上为示例,可按实际节奏层级删减或扩充。) 整体情绪在"__"与"__"之间摆动(恢复来源:__;变化来源:__)。 @@ -103,9 +108,11 @@ __。 | 验证点 | 成功标准 | |---|---| | __ | __ | +(按实际验证点逐行补充。) ## 开放问题 - __ +(按实际问题逐条补充。) ## 顶层定稿 顶层当前定稿为:__。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 38e7a2f8a..ebe6152b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -150,14 +150,26 @@ fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec = Vec::new(); + let mut pending_reasoning: Vec = Vec::new(); let mut saw_response_output = false; for item in &session.history { if item.get("role").and_then(Value::as_str) == Some("user") { if !pending_reasoning.is_empty() || !current_reasoning.is_empty() { pending_reasoning.append(&mut current_reasoning); + // A user item closes the previous turn. Resolve its reasoning + // against that turn's last assistant message before moving to + // the next group; otherwise it is incorrectly attached to the + // next turn and rendered at the bottom as an orphan. + let assistant_id = assistant_groups + .get(group_index) + .and_then(|ids| ids.last()) + .cloned(); + for mut entry in pending_reasoning.drain(..) { + entry.message_id = assistant_id.clone(); + entries.push(entry); + } } group_index += 1; assistant_index = 0; @@ -1638,6 +1650,53 @@ mod tests { ); } + #[test] + fn persisted_reasoning_stays_with_the_turn_before_an_approval_boundary() { + let mut session = new_design_session("project", "quality"); + session.messages = vec![ + DesignMessage { + id: "turn-1:user".into(), + role: "user".into(), + text: "第一轮需求".into(), + }, + DesignMessage { + id: "turn-1:assistant".into(), + role: "assistant".into(), + text: "第一轮已提交审批".into(), + }, + DesignMessage { + id: "turn-2:user".into(), + role: "user".into(), + text: "用户已批准,进入下一阶段".into(), + }, + DesignMessage { + id: "turn-2:assistant".into(), + role: "assistant".into(), + text: "查询工作阶段".into(), + }, + ]; + session.history = vec![ + json!({"role":"user", "content":"第一轮需求"}), + json!({"type":"reasoning", "id":"before-approval", "content":[{"type":"reasoning_text", "text":"审批前的思考"}]}), + json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"第一轮已提交审批"}]}), + json!({"role":"user", "content":"用户已批准,进入下一阶段"}), + json!({"type":"reasoning", "id":"after-approval", "content":[{"type":"reasoning_text", "text":"审批后的思考"}]}), + json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"查询工作阶段"}]}), + ]; + + let entries = persisted_design_reasoning_entries(&session); + assert_eq!( + entries + .iter() + .map(|entry| (entry.id.as_str(), entry.message_id.as_deref())) + .collect::>(), + vec![ + ("before-approval", Some("turn-1:assistant")), + ("after-approval", Some("turn-2:assistant")), + ] + ); + } + #[tokio::test(flavor = "current_thread")] async fn scripted_design_provider_emits_reasoning_without_persisting_it() { let (_temp, root, _resources) = init_design_project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 5d358b640..93d2146c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2349,6 +2349,13 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { ) && asset.media_type == "image/png" && asset.source.kind == GameCreationAppAssetSourceKind::Canvas && asset.local_path.starts_with("assets/") + // Canonical slices are admitted above only after the + // full slice manifest/receipt/content validation. Do + // not let this generic manifest fallback bypass it. + && !(asset.kind == "art-spritesheet-slice" + && asset + .local_path + .starts_with("assets/art-spritesheet-slices/")) }) .map(|asset| asset.local_path), ); From 6d7b3e74a615a06f04b0f406b091494bb45c61d0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 23:23:06 +0800 Subject: [PATCH 10/49] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失效进程锁自动清理后重试获取 仍存活进程持锁时继续阻断并发备份 补充锁生命周期文档与回归测试 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/check-database-backup-to-oss.mjs | 18 ++-- scripts/database-backup-to-oss.mjs | 82 ++++++++++++------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 066bc0a5d..1ca7a142f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -431,7 +431,7 @@ UI 相关修改要重点验证: npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service --restart-service-after genarrative-external-generation-worker@1.service --restart-service-after genarrative-external-generation-controller.service ``` -脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 +脚本会将数据目录打包成 `tar.gz`,上传到 `oss://///-.tar.gz`。备份使用同库进程锁:仍存活的备份进程会阻断并发执行;持有锁的进程已退出时,脚本会自动清理失效锁并重试获取,不需要人工删除锁文件。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS;因 `genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都依赖 `spacetimedb.service`,生产定时冷备份还必须传入对应的 `--restart-service-after`,确保备份后 API、保底 worker 和 controller 随数据库一起恢复。`2026-06-10` release 故障就是现场 unit 漏掉 API 重启参数,`03:20` 冷备份停止 SpacetimeDB 后 API 被依赖关系一并停止,备份脚本只恢复了 SpacetimeDB,API 直到人工重启前都不可用;`2026-06-24` release 又出现同类依赖停机后只恢复 API、未恢复外部生成 worker/controller,导致图片画布生成任务长期停留在队列中。后续现场变更、provision 模板和 Jenkins 归档都必须通过 `npm run check:production-ops` 防止回退。由于 OSS 上传可能受服务器带宽限制,`Genarrative-Stdb-Module-Publish` 默认使用 `DATABASE_BACKUP_MODE=async`:先在 publish 前用 `--defer-upload` 生成本地冷备份和 `.manifest.json`,随后继续执行 publish;发布脚本退出前会用独立 `systemd-run` transient service 执行 `--upload-deferred-dir `,串行补传该目录内同库的 `deferred/pending` 归档,不依赖 Jenkins 作业进程树存活。任一归档只有在 OSS archive、manifest 和 baseline state 全部上传并验真后,才按 `keep-local` 规则删除;失败归档保留原 manifest,由下次 publish 重试。`Genarrative-Full-Build-And-Deploy` 必须显式暴露并透传同一个 `DATABASE_BACKUP_MODE`,不得静默使用下游 `async`;release 已有验真冷备且明确禁止再上传时,Full 必须选择 `skip`。发布脚本在校验 wasm 后、执行 `spacetime publish` 前会等待显式 `SPACETIME_SERVER_URL` 的 `/v1/ping` 就绪,默认最多等待 `60` 秒;如生产机器冷备份恢复 `spacetimedb.service` 较慢,可临时设置 `GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS` 调整等待时间。需要强一致发布闸门时改用 `DATABASE_BACKUP_MODE=sync`(等价脚本参数 `--backup-mode sync`),备份会在 publish 前同步打包并上传,失败会阻断 publish;确认已有其他备份窗口时才使用 `DATABASE_BACKUP_MODE=skip`(兼容脚本参数 `--skip-backup`)。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。 生产环境变量模板在 `deploy/env/api-server.env.example`: diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index b42f5dbfc..44060f394 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -76,7 +76,7 @@ async function main() { await assertManifestUploadUsesShaAndHeadVerification(); assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas(); assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch(); - assertHistoryBackupLockRejectsLiveAndStaleOwners(); + assertHistoryBackupLockRejectsLiveAndCleansStaleOwners(); assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames(); assertHistoryStatDriftPreventsAnyCleanup(); await assertHistoryUploadFailureDoesNotDeleteSources(); @@ -2140,7 +2140,7 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { } } -function assertHistoryBackupLockRejectsLiveAndStaleOwners() { +function assertHistoryBackupLockRejectsLiveAndCleansStaleOwners() { const liveOwner = createHistoryFixture('history-live-lock', { nestedData: false, }); @@ -2176,17 +2176,17 @@ function assertHistoryBackupLockRejectsLiveAndStaleOwners() { const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']); assertStatus( staleResult, - 1, - '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。', + 0, + '失效 owner pid 的 backup lock 应自动清理并成功获取新锁。', ); assertIncludes( - staleResult.stderr, - '拒绝自动抢锁', - '失效 backup lock 应要求人工核对 multipart 与进程。', + `${staleResult.stdout}\n${staleResult.stderr}`, + '失效数据库备份锁', + '自动清理失效 backup lock 时应输出可审计日志。', ); assertTrue( - existsSync(staleLockPath), - '失效 backup lock 未经人工核对不得自动删除。', + !existsSync(staleLockPath), + '成功获取并释放新锁后,失效 backup lock 不得残留。', ); } diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 148267ac7..4b9031410 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -456,43 +456,65 @@ function acquireBackupLock({ workDir, database }) { workDir, `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`, ); - try { - const fd = openSync(lockPath, 'wx', 0o600); - writeFileSync(fd, `${process.pid}\n`, 'utf8'); - closeSync(fd); - const release = () => { - try { - const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if (ownerPid === process.pid) { - rmSync(lockPath, { force: true }); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const fd = openSync(lockPath, 'wx', 0o600); + writeFileSync(fd, `${process.pid}\n`, 'utf8'); + closeSync(fd); + const release = () => { + try { + const ownerPid = Number( + String(readFileSync(lockPath, 'utf8')).trim(), + ); + if (ownerPid === process.pid) { + rmSync(lockPath, { force: true }); + } + } catch { + // The lock may already have been removed by the normal exit path. } - } catch { - // The lock may already have been removed by the normal exit path. + }; + process.once('exit', release); + for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + release(); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); + } + return lockPath; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; } - }; - process.once('exit', release); - for (const signal of ['SIGINT', 'SIGTERM']) { - process.once(signal, () => { - release(); - process.exit(signal === 'SIGINT' ? 130 : 143); - }); } - return lockPath; - } catch (error) { - if (error?.code !== 'EEXIST') { + let ownerPid = 0; + try { + ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); + } catch (error) { + if (error?.code === 'ENOENT') { + continue; + } throw error; } - } - const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if ( - Number.isSafeInteger(ownerPid) && - ownerPid > 0 && - processIsAlive(ownerPid) - ) { - throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); + if ( + Number.isSafeInteger(ownerPid) && + ownerPid > 0 && + processIsAlive(ownerPid) + ) { + throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); + } + try { + rmSync(lockPath); + console.log( + `[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || ''}`, + ); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } } throw new Error( - `发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`, + `数据库备份锁在清理后仍无法获取,可能存在并发进程: ${lockPath}`, ); } From 34db1b6ad1c59abccb07fc17e8546bedece13a48 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 23:27:37 +0800 Subject: [PATCH 11/49] =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E9=94=81=E6=97=B6=E8=AF=AF=E5=88=A0=E6=96=B0?= =?UTF-8?q?=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用原子重命名隔离失效锁文件 --- scripts/database-backup-to-oss.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index 4b9031410..bc215509f 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -502,8 +502,12 @@ function acquireBackupLock({ workDir, database }) { ) { throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); } + const staleLockPath = `${lockPath}.stale-${process.pid}-${attempt}-${Date.now()}`; try { - rmSync(lockPath); + // rename 是原子的:若另一个进程已先清理并重新建锁,这里只会得到 ENOENT, + // 不会把新进程的锁误删。 + renameSync(lockPath, staleLockPath); + rmSync(staleLockPath, { force: true }); console.log( `[database-backup] 已清理失效数据库备份锁,准备重新获取: ${lockPath} pid=${ownerPid || ''}`, ); From 34503990f2741033e15e6edb3879b3f79238c8dc Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:29:22 +0800 Subject: [PATCH 12/49] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E6=9C=9F=E7=99=BB=E5=BD=95=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 HTTP 状态展示认证服务不可用、超时和维护提示 增加 503 非 JSON 维护页认证界面回归测试 同步 AGC 启动认证错误提示技术方案 --- .../src/services/clientAuth.ts | 28 ++++++++++++++--- .../tests/appSurface/auth.suite.ts | 31 +++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 54c4c27e8..c0b5e0750 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -72,6 +72,24 @@ export function getClientAuthRefreshOperation(apiBaseUrl: string) { const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; +function getClientAuthHttpErrorMessage(status: number, fallback: string) { + switch (status) { + case 408: + case 504: + return '登录服务响应超时,请检查服务器地址和网络后重试'; + case 429: + return '登录请求过于频繁,请稍后重试'; + case 500: + return '登录服务内部错误(HTTP 500),请稍后重试'; + case 502: + return '登录服务暂不可用:上游服务请求失败,请稍后重试'; + case 503: + return '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试'; + default: + return fallback; + } +} + function getClientAuthNetworkErrorMessage(error: unknown) { const detail = error instanceof Error ? error.message.trim() : String(error).trim(); @@ -121,24 +139,26 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { } async function readAuthErrorMessage(response: Response, fallback: string) { + const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); const text = await readClientHttpResponseText(response, { url: 'auth error response', }); if (!text.trim()) { - return fallback; + return httpFallback; } let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch { - return fallback; + return httpFallback; } try { unwrapApiResponse(parsed); } catch (error) { - return error instanceof Error ? error.message : fallback; + const message = error instanceof Error ? error.message.trim() : ''; + return message && message !== '请求失败' ? message : httpFallback; } - return fallback; + return httpFallback; } async function requestAuthJson( diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index ffeb58e41..bfb771cb0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1272,6 +1272,37 @@ export function registerAuthTests() { ).toHaveLength(1); }); + it('shows the HTTP maintenance error when startup auth receives a 503', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'existing-token', + ); + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + if (String(input) === '/api/auth/me') { + return new Response( + '503 Service Unavailable', + { status: 503, headers: { 'Content-Type': 'text/html' } }, + ); + } + throw new Error(`unexpected fetch ${String(input)}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }), + ), + ); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect( + screen.getByText( + '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试', + ), + ).not.toBeNull(); + }); + it('still calls logout when token refresh fails during logout retry', async () => { window.localStorage.setItem( 'genarrative.auth.access-token.v1', diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a232c7612..8d8af7f25 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -63,6 +63,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。 - 客户端 HTTP 传输默认使用 15 秒超时并通过独立 `AbortController` 终止请求;调用方可为确需长耗时的请求显式传入 `timeoutMs: null`。调用方主动取消仍保留原始 `AbortError`,超时使用稳定的 `ClientHttpTimeoutError`,由认证层转换为可操作的中文提示。 - 会话恢复或本地 Runner 连接超时后必须进入登录页并提供“重试登录状态检查”。重试递增恢复代次并以运行标识忽略旧恢复任务的迟到 UI 写回;不得清除仍可用于后续重试的 access token,也不得重复并发刷新同一服务器的 refresh 请求。 +- AGC 壳启动认证遇到空响应、非 JSON 维护页或 5xx 时,必须按 HTTP 状态生成可操作的中文提示(503 明确标记服务暂不可用/可能维护),不能退化为 `读取当前用户失败`;后端返回的结构化错误 message 仍优先展示。 - Tauri Runner 的启动与 IPC 超时继续以 `runner/protocol.rs` 的 30 秒启动、10 秒读写为权威;启动等待循环会把 endpoint 探测预算裁剪到剩余启动期限,避免单次 ping 把 30 秒门禁延长。考虑复用旧 endpoint 前可能先消耗一次 IPC 等待,前端 UI 兜底取 45 秒,不改变 Runner 协议、启动策略或认证接口。 ## 2026-08-26 运行中自主扩图提案 From 24bbdfda7d0a08b55e3326e99d1a6de07de3f08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 14:45:58 +0800 Subject: [PATCH 13/49] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20Response=20item=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Rust canonical user item 与 Codex wire 转换模块 增加 ts-rs 绑定目录及生成文件检查边界 更新聊天素材引用规范与里程碑实施计划 --- .eslintrc.cjs | 1 + .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_user_item.rs | 231 ++++++++++++++++++ .../generated/DirectCodexUserContentPart.ts | 10 + .../generated/DirectCodexUserItem.ts | 5 + .../generated/DirectCodexUserMessageItem.ts | 11 + .../generated/DirectCodexUserRole.ts | 3 + .../DirectCodexUserRuntimeRegionPart.ts | 13 + .../project-workspace/generated/index.ts | 5 + ...DirectProject用户ResponseItem输入-2026-09-15.md | 36 +++ ...DirectProject用户ResponseItem输入-2026-09-15.md | 51 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 8 +- 12 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts create mode 100644 docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 793e2b4a4..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,6 +164,7 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index b9d90f269..3940aeeb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -17,6 +17,7 @@ mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_references; +mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; @@ -46,6 +47,7 @@ pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_references::*; +pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs new file mode 100644 index 000000000..1c042b25f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs @@ -0,0 +1,231 @@ +use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use super::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +/// +/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item +/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts new file mode 100644 index 000000000..10c4e0f5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -0,0 +1,10 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; + +export type DirectCodexUserContentPart = + | { type: 'input_text'; text: string } + | { type: 'agc_resource_reference'; resourceId: string } + | ({ + type: 'agc_runtime_region_reference'; + } & DirectCodexUserRuntimeRegionPart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts new file mode 100644 index 000000000..4c9ea8ca3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -0,0 +1,5 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; + +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts new file mode 100644 index 000000000..f7447dc5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -0,0 +1,11 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +import type { DirectCodexUserRole } from './DirectCodexUserRole'; + +export type DirectCodexUserMessageItem = { + type: 'message'; + role: DirectCodexUserRole; + content: DirectCodexUserContentPart[]; + id: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts new file mode 100644 index 000000000..e1dc59c59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -0,0 +1,3 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts new file mode 100644 index 000000000..296c0d19a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -0,0 +1,13 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRuntimeRegionPart = { + label: string; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts new file mode 100644 index 000000000..f1012a4c5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -0,0 +1,5 @@ +export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserItem } from './DirectCodexUserItem'; +export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +export type { DirectCodexUserRole } from './DirectCodexUserRole'; +export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..8742e52fb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,36 @@ +# 【实施计划】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 + +## 实现顺序 + +1. 提取深模块:Rust canonical user item 的定义、校验和 Codex wire 转换。 +2. 生成并接入 ts-rs 类型,排除生成文件 lint/style。 +3. 将前端 Lexical 草稿从 `text + references[]` 改为 inline content parts。 +4. 修改 Tauri command 与 DirectProject turn:校验通过后持久化 canonical item,再发送转换后的 Codex 输入。 +5. 删除本链路对 legacy conversation 行的读取 fallback,保留标准 `response_item`。 +6. 补齐定向测试与文档验证;每个独立切片形成小提交。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex` +2. 相关前端 Vitest 与类型检查。 +3. `npm run check:encoding` +4. `npm run check:doc-index` +5. `git diff --check` + +## 风险与回滚点 + +- ts-rs 生成路径或 workspace lint 配置不一致:先固定生成入口,再接入业务。 +- 历史中 canonical item 含 AGC part 时,thread replay 必须经过同一 wire converter;转换失败不得启动 turn。 +- 共享工作树存在用户 `.env` 修改,禁止覆盖或提交。 +- 每个切片保持独立提交,出现协议问题时按提交粒度回滚。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..cc88e97af --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-15 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +将 DirectProject 的用户消息升级为受限 Response API `message` item:文本与 AGC 引用按编辑顺序内联,Rust 校验后持久化 canonical item,并转换为 Codex 可接受的输入。 + +## 范围 + +- Rust 定义 user item 与 content part,并通过 `ts-rs` 生成 TypeScript 绑定。 +- 前端 Lexical 草稿生成 inline `content[]`。 +- `agc_resource_reference` 只使用稳定 `resourceId`。 +- `agc_runtime_region_reference` 保留运行区域语义摘要。 +- Rust 在持久化前完成白名单、manifest 与路径校验。 +- 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 +- 保持 assistant 返回、工具 activity、附件/图片协议不变。 + +## 不在范围内 + +- assistant item 前端投影或 Tauri 返回值改造。 +- 工具 item、reasoning、file change、MCP item 的 UI 模型化。 +- 附件/图片 content part。 +- SpacetimeDB schema 或 HTTP API 变更。 + +## 依赖与前置条件 + +- DirectProject 现有 app-server thread/inject_items/turn/start 链路。 +- 项目 manifest 作为资源身份与路径权威。 +- 现有 `project.jsonl` 的 `response_item` envelope。 + +## 验收标准 + +- [ ] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。 +- [ ] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。 +- [ ] runtime-region 字段经过 Rust 有界清洗并验证关联资源。 +- [ ] 未知 part、失效资源或非法路径在持久化前失败关闭。 +- [ ] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 +- [ ] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 +- [ ] assistant、附件和工具链路行为无变化。 + +## 证据要求 + +- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。 +- 运行时:DirectProject 本地 app-server smoke(如环境可用)。 +- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index e8a921a69..ca8874954 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -13,7 +13,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 -提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。 +提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 当前已完成: @@ -24,9 +24,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 素材芯片可插入、编辑和删除; - 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; -- 提交请求携带结构化 `references`; -- Rust 按 manifest 二次校验并生成安全投影; +- 提交请求携带 canonical user message item; +- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; - 普通无引用消息保持原有行为; - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 资源改名后引用芯片与候选列表的显示名自动刷新; -- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。 +- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。 From 76bbeb68483bd364468bfb2cb8b59c287197cf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:19:17 +0800 Subject: [PATCH 14/49] =?UTF-8?q?=E9=87=8D=E6=9E=84=20DirectProject=20Agen?= =?UTF-8?q?t=20=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 direct_runtime 与 codex_app_server 改为目录模块 拆分 user item 的模型、校验与 wire 投影文件 抽离 DirectProject 历史注入载荷构造 seam --- .../direct_project_history_wire.rs | 25 ++ .../mod.rs} | 50 ++-- .../src/agent/direct_codex_user_item.rs | 231 ------------------ .../src/agent/direct_codex_user_item/mod.rs | 12 + .../src/agent/direct_codex_user_item/model.rs | 69 ++++++ .../direct_codex_user_item/validation.rs | 87 +++++++ .../src/agent/direct_codex_user_item/wire.rs | 82 +++++++ .../mod.rs} | 107 ++------ .../src/agent/direct_runtime/user_input.rs | 84 +++++++ 9 files changed, 402 insertions(+), 345 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server.rs => codex_app_server/mod.rs} (99%) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{direct_runtime.rs => direct_runtime/mod.rs} (99%) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs new file mode 100644 index 000000000..b958bd982 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -0,0 +1,25 @@ +//! DirectProject 历史注入载荷的单一构造 seam。 +//! +//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。 + +use super::super::*; +use super::direct_project_history_injection_oversize_error; +use serde_json::Value; +use std::path::Path; + +pub(super) fn build_direct_project_history_injection_params( + history_root: &Path, + thread_id: &str, +) -> Result { + let items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let params = serde_json::json!({"threadId": thread_id, "items": items}); + let payload_bytes = serde_json::to_vec(¶ms) + .map(|bytes| bytes.len().saturating_add(1)) + .unwrap_or(usize::MAX); + // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 + if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + Ok(params) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ea4d981a8..506b038f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -10,6 +10,9 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use uuid::Uuid; +mod direct_project_history_wire; +use direct_project_history_wire::build_direct_project_history_injection_params; + const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = @@ -2665,6 +2668,7 @@ impl CodexAppServerConnection { request, None, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2679,6 +2683,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, + direct_user_item: Option<&serde_json::Value>, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, @@ -2694,12 +2699,15 @@ impl CodexAppServerConnection { )); } if let Some(client_turn_id) = direct_client_turn_id { - let user_item = direct_project_local_message_item( - "user", - current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let user_item = match direct_user_item { + Some(item) => item.clone(), + None => direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?, + }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; } @@ -2709,24 +2717,14 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let items = match read_direct_project_history_items_at(history_root) { - Ok(items) => items, - Err(error) => { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } - }; - let params = serde_json::json!({"threadId": thread_id.clone(), "items": items}); - let payload_bytes = serde_json::to_vec(¶ms) - .map(|bytes| bytes.len().saturating_add(1)) - .unwrap_or(usize::MAX); - // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 - if let Some(error) = - direct_project_history_injection_oversize_error(¶ms, payload_bytes) - { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } + let params = + match build_direct_project_history_injection_params(history_root, &thread_id) { + Ok(params) => params, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error); + } + }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; return Err(platform_llm::LlmError::Transport(error)); @@ -3757,6 +3755,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( None, None, None, + None, ) .await } @@ -3774,6 +3773,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( None, Some(observer), None, + None, ) .await } @@ -3831,6 +3831,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -3894,6 +3895,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request, Some(&codex_root), effective_client_turn_id, + direct_user_item.as_ref(), None, observer, audit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs deleted file mode 100644 index 1c042b25f..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; -use super::*; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// DirectProject 本轮 user input 的唯一结构化入口。 -/// -/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item -/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserItem { - #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, - pub(crate) id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserRole { - User, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserContentPart { - #[serde(rename = "input_text")] - InputText { text: String }, - #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, - #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { - pub(crate) label: String, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - -pub(crate) fn validate_direct_codex_user_item( - root: &Path, - item: &DirectCodexUserItem, -) -> Result<(), String> { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { - return Err("DirectProject 只接受 user message item".to_string()); - } - if message.id.trim().is_empty() { - return Err("DirectProject user item 缺少稳定 id".to_string()); - } - if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); - } - let manifest = read_manifest_for_project(root)?; - let mut reference_count = 0usize; - for part in &message.content { - match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - reference_count = reference_count.saturating_add(1); - validate_resource_id_and_manifest(&manifest, resource_id)?; - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - reference_count = reference_count.saturating_add(1); - validate_runtime_region_reference(&manifest, reference)?; - } - } - } - if reference_count > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - Ok(()) -} - -fn validate_resource_id_and_manifest( - manifest: &GameCreationAppManifest, - resource_id: &str, -) -> Result<(), String> { - let resource_id = resource_id.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > 200 - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - Ok(()) -} - -fn validate_runtime_region_reference( - manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, -) -> Result<(), String> { - if reference.label.trim().is_empty() { - return Err("运行画面区域缺少名称".to_string()); - } - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" - )); - } - for resource_id in &reference.resource_ids { - validate_resource_id_and_manifest(manifest, resource_id)?; - } - Ok(()) -} - -/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 -/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 -pub(crate) fn direct_codex_user_item_to_wire_input( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - validate_direct_codex_user_item(root, item)?; - let manifest = read_manifest_for_project(root)?; - let DirectCodexUserItem::Message(message) = item; - let mut input = Vec::with_capacity(message.content.len()); - for part in &message.content { - let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); - } - summary.push(']'); - summary - } - }; - input.push(serde_json::json!({ "type": "text", "text": text })); - } - Ok(Value::Array(input)) -} - -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { - parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs new file mode 100644 index 000000000..d591f4dad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -0,0 +1,12 @@ +//! DirectProject user input 的 canonical Response item 深模块。 + +mod model; +mod validation; +mod wire; + +pub(crate) use model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, +}; +pub(crate) use validation::validate_direct_codex_user_item; +pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs new file mode 100644 index 000000000..f3837c648 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs new file mode 100644 index 000000000..ed7bf88ee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -0,0 +1,87 @@ +use super::model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, + DirectCodexUserRuntimeRegionPart, +}; +use crate::agent::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, +}; +use std::path::Path; + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +pub(crate) fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs new file mode 100644 index 000000000..c2ab37897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -0,0 +1,82 @@ +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::validation::validate_direct_codex_user_item; +use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use serde_json::Value; +use std::path::Path; + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 93d2146c4..70abd71e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +mod user_input; +pub(crate) use user_input::chat_with_game_creator_direct_codex; + const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; @@ -4189,6 +4192,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( creation_type, None, None, + None, ) .await } @@ -4199,6 +4203,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -4214,7 +4219,15 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( if let Some(emitter) = turn_emitter { emitter.emit("accepted", Some("request-accepted"), None); } - match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await + match run_direct_game_creator_turn_inner( + root, + prompt, + creation_type, + turn_emitter, + audit, + direct_user_item, + ) + .await { Ok(reply) => Ok(reply), Err(failure) => { @@ -4243,6 +4256,7 @@ async fn run_direct_game_creator_turn_inner( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -4296,6 +4310,7 @@ async fn run_direct_game_creator_turn_inner( Some(&client_turn_id), Some(&mut observer), audit, + direct_user_item.clone(), ) .await } else { @@ -4306,6 +4321,7 @@ async fn run_direct_game_creator_turn_inner( None, None, audit, + direct_user_item.clone(), ) .await } @@ -4597,95 +4613,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } -fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { - let Some(client_turn_id) = client_turn_id else { - return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); - }; - let client_turn_id = client_turn_id.trim(); - let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) - .contains(&client_turn_id.len()); - let mut bytes = client_turn_id.bytes(); - let valid_first = bytes - .next() - .is_some_and(|byte| byte.is_ascii_alphanumeric()); - let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); - if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); - } - Ok(client_turn_id.to_string()) -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( - project_path: String, - prompt: String, - creation_type: Option, - client_turn_id: Option, - attachments: Option>, - references: Option>, -) -> Result { - let root = Path::new(project_path.trim()); - let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; - recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) - })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - let references = references.unwrap_or_default(); - let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) { - Ok(prompt) => prompt, - Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(), - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - if let Some(reference_section) = match render_direct_codex_references_section(root, &references) - { - Ok(section) => section, - Err(error) => { - audit.finish(false); - return Err(error); - } - } { - if !user_prompt.trim().is_empty() { - user_prompt.push_str("\n\n"); - } - user_prompt.push_str(&reference_section); - } - if user_prompt.trim().is_empty() { - audit.finish(false); - return Err("聊天内容不能为空".to_string()); - } - let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - &user_prompt, - creation_type.as_deref(), - Some(&turn_emitter), - Some(&mut audit), - ) - .await - { - Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); - Ok(reply) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs new file mode 100644 index 000000000..4188bfd8f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -0,0 +1,84 @@ +//! DirectProject 用户输入命令适配器。 +//! +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item +//! 深模块,回合编排仍由父模块负责。 + +use super::*; + +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + attachments: Option>, +) -> Result { + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { + redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) + })?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); + let mut audit = DirectCodexTurnAudit::start( + root, + &turn_id, + &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let attachments = attachments.unwrap_or_default(); + validate_direct_codex_user_item(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + if user_prompt.trim().is_empty() { + audit.finish(false); + return Err("聊天内容不能为空".to_string()); + } + let canonical_user_item = + Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?); + let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + &user_prompt, + creation_type.as_deref(), + Some(&turn_emitter), + Some(&mut audit), + canonical_user_item, + ) + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + audit.finish(true); + turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + Ok(reply) +} From 5bc734030493aeadeb36392d16ab91eca641cd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:22:39 +0800 Subject: [PATCH 15/49] =?UTF-8?q?=E6=8A=BD=E7=A6=BB=20DirectProject=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=A7=A3=E6=9E=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将项目 canonical identity 与 digest 放入独立文件 保持 Codex app-server 线程池调用接口不变 --- .../direct_project_identity.rs | 50 +++++++++++++++++++ .../src/agent/codex_app_server/mod.rs | 48 +----------------- 2 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs new file mode 100644 index 000000000..4454a2a48 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -0,0 +1,50 @@ +//! DirectProject 线程池身份的 canonical 解析与摘要。 + +use super::super::*; +use sha2::{Digest, Sha256}; + +pub(super) fn direct_codex_canonical_project_identity( + root: &std::path::Path, +) -> Result<(std::path::PathBuf, String), String> { + let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; + let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) + .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; + let manifest_project_id = manifest.project_id.trim(); + if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { + return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); + } + let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); + Ok(( + canonical_root, + direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), + )) +} + +fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return path.as_os_str().as_bytes().to_vec(); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + return bytes; + } + #[cfg(not(any(unix, windows)))] + path.as_os_str().to_string_lossy().as_bytes().to_vec() +} + +fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-direct-project-identity.v1\0"); + digest.update((path_identity.len() as u64).to_le_bytes()); + digest.update(path_identity); + digest.update((project_id.len() as u64).to_le_bytes()); + digest.update(project_id); + format!("{:x}", digest.finalize()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 506b038f4..d6174b5d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -12,6 +12,8 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; +mod direct_project_identity; +use direct_project_identity::*; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -3778,52 +3780,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( .await } -fn direct_codex_canonical_project_identity( - root: &std::path::Path, -) -> Result<(std::path::PathBuf, String), String> { - let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; - let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) - .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; - let manifest_project_id = manifest.project_id.trim(); - if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { - return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); - } - let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); - Ok(( - canonical_root, - direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), - )) -} - -fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - return path.as_os_str().as_bytes().to_vec(); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - let mut bytes = Vec::new(); - for unit in path.as_os_str().encode_wide() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - return bytes; - } - #[cfg(not(any(unix, windows)))] - path.as_os_str().to_string_lossy().as_bytes().to_vec() -} - -fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { - let mut digest = Sha256::new(); - digest.update(b"genarrative-direct-project-identity.v1\0"); - digest.update((path_identity.len() as u64).to_le_bytes()); - digest.update(path_identity); - digest.update((project_id.len() as u64).to_le_bytes()); - digest.update(project_id); - format!("{:x}", digest.finalize()) -} - pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, From d48d09905dd604ceb4c6de4565b45f74f47d3d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:42:27 +0800 Subject: [PATCH 16/49] =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E4=BD=BF=E7=94=A8=20Di?= =?UTF-8?q?rectProject=20Response=20item=20=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 game-creator-conversation.v1 读取 fallback 历史注入前逐项投影 canonical user item 为 Codex message 无效 item 在持久化前失败关闭 --- .../direct_project_history_wire.rs | 9 +- .../src/agent/codex_app_server/mod.rs | 6 +- .../src/agent/direct_codex_user_item/mod.rs | 5 +- .../src/agent/direct_codex_user_item/wire.rs | 30 +++ .../src/agent/direct_project_history.rs | 240 ++---------------- 5 files changed, 66 insertions(+), 224 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index b958bd982..627002154 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -11,8 +11,15 @@ pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, ) -> Result { - let items = read_direct_project_history_items_at(history_root) + let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + let items = canonical_items + .iter() + .map(|item| { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest) + }) + .collect::, _>>()?; let params = serde_json::json!({"threadId": thread_id, "items": items}); let payload_bytes = serde_json::to_vec(¶ms) .map(|bytes| bytes.len().saturating_add(1)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index d6174b5d8..a86ef26b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2702,7 +2702,11 @@ impl CodexAppServerConnection { } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { - Some(item) => item.clone(), + Some(item) => { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + item.clone() + } None => direct_project_local_message_item( "user", current_prompt, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d591f4dad..d5ccc94c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -9,4 +9,7 @@ pub(crate) use model::{ DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; -pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; +pub(crate) use wire::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + direct_codex_user_item_to_wire_input, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c2ab37897..1a07d2c95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,6 +4,36 @@ use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use serde_json::Value; use std::path::Path; +/// 将历史中的 canonical user item 投影为 Codex `response_item` message。 +/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 +pub(crate) fn direct_codex_user_item_to_response_item( + root: &Path, + item: &Value, +) -> Result { + let is_user_message = item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user"); + if !is_user_message { + if item.get("type").and_then(Value::as_str).is_some() { + return Ok(item.clone()); + } + return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); + } + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; + let Value::Array(content) = direct_codex_user_item_to_wire_input(root, &canonical)? else { + return Err("DirectProject user item wire content 不是数组".to_string()); + }; + let mut projected = serde_json::json!({ + "type": "message", + "role": "user", + "content": content, + }); + if let Some(id) = item.get("id").and_then(Value::as_str) { + projected["id"] = Value::String(id.to_string()); + } + Ok(projected) +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index df80cc7ca..086a9b1f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -17,9 +17,6 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; /// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, /// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; -/// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 -/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 -const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ "host_skills.instructions", "permissions.instructions", @@ -180,79 +177,22 @@ fn direct_project_history_item_from_line( Ok(Some(item)) } -/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已 -/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。 -/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在 -/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。 +/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。 fn direct_project_history_item_from_parsed_line( path: &Path, parsed: &Value, ) -> Result, String> { - if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { - return parsed - .get("payload") - .cloned() - .map(Some) - .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())); + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); } - direct_project_legacy_row(parsed) - .map(|row| row.message_item()) - .ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display())) -} - -/// 白名单化 legacy 行的投影结果。 -enum DirectProjectLegacyRow { - /// user/assistant 行:投影成 message item 进入历史。 - Message(Value), - /// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex - /// 上下文。legacy 行不是 Responses item,`tool` 行无法还原成真正的工具 item, - /// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。 - NotChat, -} - -impl DirectProjectLegacyRow { - fn message_item(self) -> Option { - match self { - DirectProjectLegacyRow::Message(item) => Some(item), - DirectProjectLegacyRow::NotChat => None, - } - } -} - -/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与 -/// `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` -/// 原样保留(不 trim、不改写、不合并),未知字段忽略。 -/// -/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是 -/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一 -/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来; -/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。 -fn direct_project_legacy_row(parsed: &Value) -> Option { - let object = parsed.as_object()?; - if object.contains_key("type") - || object.get("schemaVersion").and_then(Value::as_str) - != Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION) - { - return None; - } - let role = object.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant" | "tool") { - return None; - } - let content = object.get("content").and_then(Value::as_str)?; - if content.is_empty() { - return None; - } - if role == "tool" { - return Some(DirectProjectLegacyRow::NotChat); - } - let message_id = object - .get("messageId") - .and_then(Value::as_str) - .filter(|message_id| !message_id.is_empty()); - Some(DirectProjectLegacyRow::Message( - direct_project_message_item(role, content, message_id), - )) + parsed + .get("payload") + .cloned() + .map(Some) + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())) } /// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。 @@ -637,9 +577,8 @@ mod tests { std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture"); } - const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#; - const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#; const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; + const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// @@ -648,7 +587,7 @@ mod tests { #[test] fn contention_failure_is_retried_with_bounded_backoff() { let root = init_history_project("contention-retry"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -680,7 +619,7 @@ mod tests { #[test] fn contention_failure_beyond_the_backoff_budget_fails_closed() { let root = init_history_project("contention-bounded"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -723,7 +662,7 @@ mod tests { write_history_lines( root.path(), &[ - LEGACY_USER_ROW, + RESPONSE_ITEM_ROW, r#"{"schemaVersion":"game-creator"#, RESPONSE_ITEM_ROW, ], @@ -761,7 +700,7 @@ mod tests { let root = init_history_project("scan-outside-lock"); write_history_lines( root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], + &[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW], ); let path = history_path(root.path()); let fired = Arc::new(AtomicBool::new(false)); @@ -805,7 +744,7 @@ mod tests { #[test] fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() { let root = init_history_project("scan-stale-state"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let path = history_path(root.path()); let item = json!({ "type": "message", @@ -845,7 +784,7 @@ mod tests { #[test] fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() { let root = init_history_project("id-conflict-outside-lock"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); append_direct_project_history_item_at( root.path(), &json!({ @@ -1033,91 +972,7 @@ mod tests { } #[test] - fn legacy_conversation_rows_project_into_responses_message_items() { - let root = init_history_project("legacy-projection"); - write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]); - - let items = read_direct_project_history_items_at(root.path()).expect("read legacy history"); - assert_eq!( - items, - vec![ - json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-0001:user", - "content": [{"type": "input_text", "text": "请创建菜单"}], - }), - json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}], - }), - ] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat"); - assert_eq!(chat.messages.len(), 2); - assert_eq!(chat.messages[0].role, "user"); - assert_eq!(chat.messages[0].content, "请创建菜单"); - assert_eq!( - chat.messages[0].message_id.as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(chat.messages[1].role, "assistant"); - assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 "); - assert_eq!(chat.messages[1].message_id, None); - } - - #[test] - fn mixed_legacy_and_response_item_history_reads_in_file_order() { - let root = init_history_project("mixed-history"); - write_history_lines( - root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read mixed history"); - assert_eq!( - items - .iter() - .map(|item| item.get("id").and_then(Value::as_str)) - .collect::>(), - vec![ - Some("direct-codex:turn-0001:user"), - Some("codex-item-2"), - None - ] - ); - assert_eq!( - items[1], - json!({ - "type": "message", - "role": "user", - "id": "codex-item-2", - "content": [{"type": "input_text", "text": "再加一个按钮"}], - }) - ); - assert_eq!( - items[2]["content"][0]["text"], - json!("已完成 第一行\n第二行 ") - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| (message.role.as_str(), message.content.as_str())) - .collect::>(), - vec![ - ("user", "请创建菜单"), - ("user", "再加一个按钮"), - ("assistant", "已完成 第一行\n第二行 "), - ] - ); - } - - #[test] - fn legacy_compat_keeps_unrecognized_rows_failing_closed() { + fn non_response_item_history_fails_closed() { let unrecognized_rows = [ // 带 type 却不是 response_item r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#, @@ -1159,61 +1014,4 @@ mod tests { .expect_err("broken json must fail closed"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } - - #[test] - fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() { - let root = init_history_project("legacy-tool-row"); - write_history_lines( - root.path(), - &[ - LEGACY_USER_ROW, - r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#, - LEGACY_ASSISTANT_ROW, - ], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read history"); - assert_eq!( - items - .iter() - .map(|item| item["role"].as_str().unwrap_or_default()) - .collect::>(), - vec!["user", "assistant"] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| message.role.as_str()) - .collect::>(), - vec!["user", "assistant"] - ); - } - - #[test] - fn generic_writer_rows_are_covered_by_the_legacy_whitelist() { - let root = init_history_project("generic-writer-shape"); - crate::append_local_conversation_message_at( - root.path(), - None, - crate::LocalConversationMessage { - role: "user".to_string(), - content: "通用写入器写的旧格式回合".to_string(), - agent_id: None, - }, - ) - .expect("append legacy row through the generic writer"); - - let items = - read_direct_project_history_items_at(root.path()).expect("read generic writer row"); - assert_eq!( - items, - vec![json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}], - })] - ); - } } From 6d190e2f8c5a55ad1cfa873eaf694697b27851ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:55:15 +0800 Subject: [PATCH 17/49] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Response=20item=20?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=90=88=E5=90=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝资源引用额外身份字段与未知 content part 补充历史 item wire 投影边界测试 同步 DirectProject 历史文档为无 fallback 合同 --- .../src/agent/direct_codex_user_item/model.rs | 66 +++++++++++++++++-- .../src/agent/direct_codex_user_item/wire.rs | 31 +++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 12 ++-- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index f3837c648..b46336b66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -3,7 +3,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] @@ -11,7 +11,7 @@ pub(crate) enum DirectCodexUserItem { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, @@ -27,7 +27,7 @@ pub(crate) enum DirectCodexUserRole { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] @@ -39,7 +39,7 @@ pub(crate) enum DirectCodexUserContentPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, @@ -62,8 +62,64 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageEnvelope { pub(crate) item: DirectCodexUserItem, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resource_reference_serializes_with_only_camel_case_resource_id() { + let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: DirectCodexUserRole::User, + content: vec![DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }], + id: "turn-1".to_string(), + }); + assert_eq!( + serde_json::to_value(item).expect("serialize user item"), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + }) + ); + } + + #[test] + fn resource_reference_rejects_extra_identity_fields() { + let error = serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero", + "label": "主角" + }], + "id": "turn-1" + })) + .expect_err("label must not be accepted on resource reference"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } + + #[test] + fn unknown_content_part_fails_closed() { + serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{"type": "future_part", "value": "x"}], + "id": "turn-1" + })) + .expect_err("unknown content part must fail closed"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 1a07d2c95..99d4262c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -110,3 +110,34 @@ pub(crate) fn direct_codex_user_item_to_prompt( } }) } + +#[cfg(test)] +mod tests { + use super::direct_codex_user_item_to_response_item; + use serde_json::json; + use std::path::Path; + + #[test] + fn standard_response_item_passes_through_without_agc_private_parts() { + let item = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "完成"}] + }); + assert_eq!( + direct_codex_user_item_to_response_item(Path::new("/unused"), &item) + .expect("assistant response item should pass through"), + item + ); + } + + #[test] + fn history_item_without_type_fails_closed() { + let error = direct_codex_user_item_to_response_item( + Path::new("/unused"), + &json!({"role": "assistant"}), + ) + .expect_err("history item without type must fail"); + assert!(error.contains("缺少 type"), "{error}"); + } +} diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index bf46784af..17bd7c6a3 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -16,16 +16,16 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 -读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。 +读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -55,12 +55,10 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 -`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。 - -这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。 +`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 ## 写入与损坏边界 -写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。 +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。 该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。 From 152041cd1971d8bcbc906b14ca463c9bd6caa007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 16:01:45 +0800 Subject: [PATCH 18/49] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7=20DirectProje?= =?UTF-8?q?ct=20=E5=BC=95=E7=94=A8=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 DirectCodexTurnReference 平行 DTO 与渲染路径 将引用数量上限收回 canonical user item 校验模块 --- .../src-tauri/src/agent.rs | 2 - .../src/agent/direct_codex_references.rs | 382 ------------------ .../direct_codex_user_item/validation.rs | 3 +- 3 files changed, 2 insertions(+), 385 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 3940aeeb7..d084e6174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -16,7 +16,6 @@ mod design_runtime; mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; -mod direct_codex_references; mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; @@ -46,7 +45,6 @@ pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; -pub(crate) use direct_codex_references::*; pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs deleted file mode 100644 index f1b78b1ca..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::*; - -pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200; -const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160; -const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80; -const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240; - -const DIRECT_CODEX_REFERENCE_HEADER: &str = - "[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]"; - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub(crate) enum DirectCodexTurnReference { - #[serde(rename = "resource")] - Resource(DirectCodexResourceReference), - #[serde(rename = "runtime-region")] - RuntimeRegion(DirectCodexRuntimeRegionReference), -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexResourceReference { - pub(crate) resource_id: String, - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) source: Option, -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexRuntimeRegionReference { - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -fn sanitize_reference_text(value: &str, max_chars: usize) -> Option { - let sanitized = value - .trim() - .chars() - .filter(|character| !character.is_control()) - .take(max_chars) - .collect::(); - if sanitized.is_empty() { - None - } else { - Some(sanitized) - } -} - -fn sanitize_reference_label(value: Option<&str>) -> Option { - value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)) -} - -fn sanitize_reference_source(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_element(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_dimension(value: Option) -> Option { - value - .filter(|value| value.is_finite() && *value > 0.0) - .map(|value| value.round().clamp(1.0, 100_000.0) as u32) -} - -fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String { - let basename = asset - .local_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(&asset.id); - let without_extension = basename - .rsplit_once('.') - .map(|(name, _)| name) - .unwrap_or(basename) - .trim(); - if without_extension.is_empty() { - asset.id.clone() - } else { - without_extension - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS) - .collect() - } -} - -fn validate_resource_reference_id(value: &str) -> Result { - let resource_id = value.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - Ok(resource_id.to_string()) -} - -fn render_resource_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexResourceReference, -) -> Result { - let resource_id = validate_resource_reference_id(&reference.resource_id)?; - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let local_path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| asset_display_label(asset)); - let source = sanitize_reference_source(reference.source.as_deref()) - .unwrap_or_else(|| "unknown".to_string()); - Ok(format!( - "- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}", - asset.kind, asset.media_type - )) -} - -fn render_runtime_region_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexRuntimeRegionReference, -) -> Result { - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| "运行画面区域".to_string()); - let run_id = sanitize_reference_source(reference.run_id.as_deref()); - let version_id = sanitize_reference_source(reference.version_id.as_deref()); - let element_tag = sanitize_reference_element(reference.element_tag.as_deref()); - let element_role = sanitize_reference_element(reference.element_role.as_deref()); - let text = reference - .text - .as_deref() - .and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS)); - let width = sanitize_reference_dimension(reference.width); - let height = sanitize_reference_dimension(reference.height); - - // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, - // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 - // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 - // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" - )); - } - let mut related_resource_ids = Vec::new(); - for resource_id in &reference.resource_ids { - let resource_id = validate_resource_reference_id(resource_id)?; - if !manifest.assets.iter().any(|asset| asset.id == resource_id) { - return Err("运行画面引用的素材已变化,请重新点选".to_string()); - } - // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 - // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 - if !related_resource_ids.contains(&resource_id) { - related_resource_ids.push(resource_id); - } - } - - let mut parts = vec![format!("名称:{label}")]; - if let Some(run_id) = run_id { - parts.push(format!("运行标识:{run_id}")); - } - if let Some(version_id) = version_id { - parts.push(format!("版本标识:{version_id}")); - } - if let Some(element_tag) = element_tag { - parts.push(format!("元素:{element_tag}")); - } - if let Some(element_role) = element_role { - parts.push(format!("角色:{element_role}")); - } - if let Some(text) = text { - parts.push(format!("文本摘要:{text}")); - } - if let (Some(width), Some(height)) = (width, height) { - parts.push(format!("尺寸:{width}x{height}")); - } - if !related_resource_ids.is_empty() { - parts.push(format!("关联素材 ID:{}", related_resource_ids.join(","))); - } - Ok(format!("- 运行画面区域:{}", parts.join(";"))) -} - -pub(crate) fn render_direct_codex_references_section( - root: &Path, - references: &[DirectCodexTurnReference], -) -> Result, String> { - if references.is_empty() { - return Ok(None); - } - if references.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::with_capacity(references.len()); - for reference in references { - lines.push(match reference { - DirectCodexTurnReference::Resource(reference) => { - render_resource_reference_line(&manifest, reference)? - } - DirectCodexTurnReference::RuntimeRegion(reference) => { - render_runtime_region_reference_line(&manifest, reference)? - } - }); - } - Ok(Some( - std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string()) - .chain(lines) - .collect::>() - .join("\n"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_project() -> tempfile::TempDir { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - std::fs::create_dir_all(root.join(".agent")).expect("create agent dir"); - let mut manifest = new_game_creation_app_manifest("project-1", "测试项目"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "asset-hero".to_string(), - kind: "character".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/hero.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - image_sequence_frames: None, - image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), - tags: Vec::new(), - }); - write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); - directory - } - - #[test] - fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#, - ) - .expect("reference json"); - let section = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect("render") - .expect("section"); - assert!(section.contains("素材 ID:asset-hero")); - assert!(section.contains("名称:主角")); - assert!(section.contains("项目路径:assets/hero.png")); - assert!(!section.contains("C:\\secret.png")); - } - - #[test] - fn deleted_resource_fails_closed() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#, - ) - .expect("reference json"); - let error = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect_err("missing resource"); - assert_eq!(error, "引用的素材已不存在,请移除后重新选择"); - } - - #[test] - fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":" ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index dd699846a..05e7c1056 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -157,6 +157,7 @@ type ProjectWorkspaceChatPaneProps = { handleRevealCurrentProjectDirectory: () => Promise; handleRuntimeConfigOpen: () => void; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; llmConfigStatus: GameCreatorLlmConfigStatus | null; loadProjectConversation: ( nextProjectPath: string, @@ -256,6 +257,7 @@ export function ProjectWorkspaceChatPane({ handleRevealCurrentProjectDirectory, handleRuntimeConfigOpen, hiddenConversationCount, + hasEarlierConversationMessages = false, llmConfigStatus, loadProjectConversation, localProject, @@ -809,13 +811,15 @@ export function ProjectWorkspaceChatPane({ ) : null}
- {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index c4aa1640c..5e4a391e5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -71,6 +71,7 @@ type SupervisorChatOnlyViewProps = { transientReply: string; hasConversationControls: boolean; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; needsUserInput: boolean; visibleMessages: ChatMessage[]; workspaceStatus: string; @@ -108,6 +109,7 @@ export function SupervisorChatOnlyView({ transientReply, hasConversationControls, hiddenConversationCount, + hasEarlierConversationMessages = false, needsUserInput, visibleMessages, workspaceStatus, @@ -188,12 +190,16 @@ export function SupervisorChatOnlyView({ aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'} onScroll={handleScroll} > - {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( + > + {hiddenConversationCount > 0 + ? `显示更早 · 还有 ${hiddenConversationCount} 条对话` + : '显示更早的对话'} + ) : null} {visibleMessages.map((message, index) => (
{ } ``` -`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。 +`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图必须显示“显示更早的对话”入口,并允许按钮或滚动触发下一页,即使当前可见消息窗口没有隐藏消息。 `consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 From 01e97d3a0255a3854f91282ce2491087ad82823b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:25:55 +0800 Subject: [PATCH 48/49] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Direct=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=B7=A8=E9=A1=B5=E9=9D=A2=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增活动 Direct 回合只读快照与状态更新 接入重进项目的忙碌态恢复和运行中项目面板 补充生命周期规范、决策记录与定向测试 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 113 +++++++++++++++++ .../src/agent/runtime_driver/entrypoints.rs | 14 ++- .../src-tauri/src/commands.rs | 6 + .../src-tauri/src/main.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 24 ++++ apps/ai-game-creator-shell/src/app/types.ts | 11 ++ .../agent-runtime/directActiveTurns.ts | 110 ++++++++++++++++ .../app-shell/ActiveProjectRunsPanel.tsx | 119 ++++++++++++++++++ .../features/app-shell/WorkspaceLauncher.tsx | 17 +++ apps/ai-game-creator-shell/src/styles.css | 74 +++++++++++ .../tests/directActiveTurns.test.tsx | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 51 ++++++++ ...合跨页面生命周期与运行中项目可见性-2026-09-15.md | 33 +++++ .../shared-memory/decision-log.md | 3 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 ++ 15 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx create mode 100644 apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 769ab2566..cb27517fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5,6 +5,7 @@ use std::future::Future; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id}; @@ -266,6 +267,25 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String #[derive(Debug)] struct DirectTaonierActiveInvocation { invocation_id: String, + project_name: Option, + started_at: u64, + status: String, + activity: Option, + updated_at: u64, + sequence: u64, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectActiveTurnSnapshot { + pub(crate) project_path: String, + pub(crate) project_name: Option, + pub(crate) turn_id: String, + pub(crate) started_at: u64, + pub(crate) status: String, + pub(crate) activity: Option, + pub(crate) updated_at: u64, + pub(crate) sequence: u64, } static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< @@ -303,6 +323,18 @@ impl DirectTaonierActiveInvocationGuard { root.clone(), DirectTaonierActiveInvocation { invocation_id: invocation_id.to_string(), + project_name: root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string), + started_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: 0, + sequence: 0, }, ); } @@ -331,6 +363,57 @@ impl Drop for DirectTaonierActiveInvocationGuard { } } +pub(crate) fn list_direct_active_turns() -> Result, String> { + let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())?; + let mut turns = active + .iter() + .map(|(root, invocation)| DirectActiveTurnSnapshot { + project_path: root.to_string_lossy().into_owned(), + project_name: invocation.project_name.clone(), + turn_id: invocation.invocation_id.clone(), + started_at: invocation.started_at, + status: invocation.status.clone(), + activity: invocation.activity.clone(), + updated_at: invocation.updated_at, + sequence: invocation.sequence, + }) + .collect::>(); + turns.sort_by(|left, right| left.project_path.cmp(&right.project_path)); + Ok(turns) +} + +pub(crate) fn update_direct_active_turn( + root: &Path, + turn_id: &str, + status: &str, + activity: Option<&str>, + sequence: u64, + updated_at: u64, +) { + let Ok(root) = root.canonicalize() else { + return; + }; + let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { + return; + }; + let Ok(mut active) = active.lock() else { + return; + }; + let Some(invocation) = active.get_mut(&root) else { + return; + }; + if invocation.invocation_id != turn_id || sequence < invocation.sequence { + return; + } + invocation.status = status.to_string(); + invocation.activity = activity.map(str::to_string); + invocation.updated_at = updated_at; + invocation.sequence = sequence; +} + pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { let root = root .canonicalize() @@ -4773,6 +4856,36 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { + let root = tempfile::tempdir().expect("active snapshot root"); + let turn_id = "client-turn-snapshot-0001"; + let guard = DirectTaonierActiveInvocationGuard::enter(root.path(), turn_id) + .expect("active snapshot turn"); + update_direct_active_turn( + root.path(), + turn_id, + "streaming", + Some("response-finalization"), + 3, + 42, + ); + let snapshot = list_direct_active_turns() + .expect("list active turns") + .into_iter() + .find(|turn| turn.turn_id == turn_id) + .expect("snapshot entry"); + assert_eq!(snapshot.status, "streaming"); + assert_eq!(snapshot.activity.as_deref(), Some("response-finalization")); + assert_eq!(snapshot.sequence, 3); + assert_eq!(snapshot.updated_at, 42); + drop(guard); + assert!(list_direct_active_turns() + .expect("list after completion") + .into_iter() + .all(|turn| turn.turn_id != turn_id)); + } + #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 2a1b77488..76b85ef7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -74,15 +74,23 @@ impl DirectGameCreatorTurnUpdateEmitter { if !status_is_allowed || !activity_is_allowed { return; } - let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { - return; - }; let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; let updated_at = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() .min(u64::MAX as u128) as u64; + update_direct_active_turn( + Path::new(&self.project_path), + &self.turn_id, + status, + activity, + sequence, + updated_at, + ); + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; let _ = app.emit( "game-creator-direct-turn-update", GameCreatorDirectTurnUpdateEvent { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a17b0e3f3..6b00b2ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5279,6 +5279,12 @@ pub(crate) async fn read_agent_runtime_error_detail( .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns( +) -> Result, String> { + list_direct_active_turns() +} + #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e68dee027..660ff5699 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2770,6 +2770,7 @@ fn main() { read_local_conversation, read_direct_project_conversation, read_agent_runtime_error_detail, + list_game_creator_direct_active_turns, subscribe_direct_project_thread, consume_direct_project_thread, read_direct_project_history_slice, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9d847517d..7d0e734d6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -57,6 +57,7 @@ import type { DesignView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectActiveTurn, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -1955,6 +1956,29 @@ export function App({ }; const bootstrap = async () => { + try { + const activeTurns = await directInvoke( + 'list_game_creator_direct_active_turns', + ); + const activeTurn = activeTurns.find((turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath), + ); + if (activeTurn && !activeDirectCodexTurnRef.current) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeTurn.turnId, + lastSequence: 0, + receivedDirectUpdate: true, + }; + setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); + setChatAgentBusy(true); + setDirectCodexStatus('running'); + setDirectCodexProgress('正在处理'); + setDirectCodexProgressUpdatedAt(Date.now()); + } + } catch { + // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 + } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 64a79bfab..4093a0b1f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1258,3 +1258,14 @@ export type TauriInvoke = ( command: string, args?: Record, ) => Promise; + +export type GameCreatorDirectActiveTurn = { + projectPath: string; + projectName?: string | null; + turnId: string; + startedAt: number; + status: string; + activity?: string | null; + updatedAt: number; + sequence: number; +}; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts new file mode 100644 index 000000000..0eadbeacd --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; + +/** + * 轮询间隔:注册表是进程内只读快照,一次查询只是一次 IPC + 一次内存遍历。 + * "哪些项目正在跑"不值得再建一套事件流,而且轮询能在丢事件时自愈。 + */ +export const DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS = 5_000; + +/** + * 单次刷新里的读取尝试次数。快照读取失败最多重试 3 次,3 次全部失败才把 + * "读不到"告诉用户;但即便告诉,也只能说读取失败,不得改写成业务失败、 + * 权限问题或审批结论。 + */ +export const DIRECT_ACTIVE_TURNS_READ_ATTEMPTS = 3; +const DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS = 300; + +/** + * 当前进程里仍在跑的 Direct 回合。 + * + * 回合属于项目而不是页面:离开项目界面不会终止它,所以"谁在跑"必须从 Rust 的 + * 活动回合注册表读,而不是从当前页面的组件状态推断。读取失败保留上一份快照—— + * 读不到不等于"没有在跑",调用方不能据此阻断发送或清空状态。 + */ +export function useDirectActiveTurns({ + invoke, + enabled, + pollIntervalMs = DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, +}: { + invoke: TauriInvoke | null | undefined; + enabled: boolean; + pollIntervalMs?: number; +}) { + const [activeTurns, setActiveTurns] = useState( + [], + ); + const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); + const mountedRef = useRef(true); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refreshActiveTurns = useCallback(async () => { + if (!invoke) { + return; + } + // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 + if (inFlightRef.current) { + return inFlightRef.current; + } + const request = (async () => { + for ( + let attempt = 1; + attempt <= DIRECT_ACTIVE_TURNS_READ_ATTEMPTS; + attempt++ + ) { + try { + const turns = await invoke( + 'list_game_creator_direct_active_turns', + ); + if (!mountedRef.current) { + return; + } + setActiveTurns(Array.isArray(turns) ? turns : []); + setSnapshotReadFailed(false); + inFlightRef.current = null; + return; + } catch { + if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { + await new Promise((resolve) => + window.setTimeout( + resolve, + DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, + ), + ); + } + } + } + // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 + if (mountedRef.current) { + setSnapshotReadFailed(true); + } + inFlightRef.current = null; + })(); + inFlightRef.current = request; + return request; + }, [invoke]); + + useEffect(() => { + if (!enabled || !invoke) { + setActiveTurns([]); + setSnapshotReadFailed(false); + return; + } + void refreshActiveTurns(); + const timer = window.setInterval( + () => void refreshActiveTurns(), + Math.max(1_000, pollIntervalMs), + ); + return () => window.clearInterval(timer); + }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); + + return { activeTurns, refreshActiveTurns, snapshotReadFailed }; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx new file mode 100644 index 000000000..4147abb9b --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -0,0 +1,119 @@ +import type { GameCreatorDirectActiveTurn } from '../../app/types'; +import { projectNameFromPath } from '../agent-runtime'; +import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; + +/** + * 左上角的"正在运行的项目"面板。 + * + * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连), + * 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时 + * 整块不渲染,不留空白占位。 + */ +export type ActiveProjectRunsPanelProps = { + activeTurns: GameCreatorDirectActiveTurn[]; + currentProjectPath?: string | null; + readFailed?: boolean; + onOpenProject?: (projectPath: string) => void; +}; + +const ACTIVE_TURN_STATUS_LABELS: Record = { + accepted: '已受理', + running: '创作中', + streaming: '生成中', + finalizing: '收尾中', + completed: '已完成', + failed: '已失败', +}; + +function activeTurnStatusLabel(status: string) { + return ACTIVE_TURN_STATUS_LABELS[status] ?? '创作中'; +} + +function formatActiveTurnElapsed(startedAt: number, now: number) { + const elapsedMs = now - startedAt; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + return ''; + } + const totalMinutes = Math.floor(elapsedMs / 60_000); + if (totalMinutes < 1) { + return '不到 1 分钟'; + } + if (totalMinutes < 60) { + return `${totalMinutes} 分钟`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return minutes === 0 ? `${hours} 小时` : `${hours} 小时 ${minutes} 分`; +} + +function activeTurnDisplayName(turn: GameCreatorDirectActiveTurn) { + const snapshotName = turn.projectName?.trim(); + return snapshotName || projectNameFromPath(turn.projectPath); +} + +export function ActiveProjectRunsPanel({ + activeTurns, + currentProjectPath = null, + readFailed = false, + onOpenProject, +}: ActiveProjectRunsPanelProps) { + if (activeTurns.length === 0) { + if (!readFailed) { + return null; + } + // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 + return ( + + ); + } + + const now = Date.now(); + const orderedTurns = [...activeTurns].sort( + (left, right) => left.startedAt - right.startedAt, + ); + return ( + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 58a188e6f..c0f0edb40 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -25,8 +25,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; +import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel'; import { DeveloperAgentDialogs, DeveloperAgentPanel, @@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({ homeCreationBusy, homeCreationRecoverableProjectPath, } = homeProject; + const directInvoke = resolveTauriInvoke(); + const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({ + invoke: directInvoke, + enabled: true, + }); const switchedToGameRuntime = gameRuntimeSwitch !== null && currentProjectContext !== null && @@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({ ) : null} + { + setProjectPath(nextProjectPath); + void openProject(nextProjectPath, 'open'); + }} + /> + {launcherView === 'home' ? ( 0} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8f92d51fe..6dfa52a06 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -903,6 +903,80 @@ textarea { overflow: hidden; } +.launcher-runs-panel { + position: absolute; + top: 16px; + left: 24px; + z-index: 3; + width: min(280px, calc(100% - 260px)); + padding: 10px 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + box-shadow: 0 8px 24px rgb(31 24 16 / 8%); +} + +.launcher-runs-panel-header { + display: flex; + align-items: center; + gap: 7px; + color: var(--platform-text-strong); + font-size: 12px; +} + +.launcher-runs-panel-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4d9f54; + box-shadow: 0 0 0 3px rgb(77 159 84 / 14%); +} + +.launcher-runs-panel-list { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.launcher-runs-panel-item { + display: grid; + width: 100%; + gap: 2px; + padding: 6px 7px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.launcher-runs-panel-item:hover, +.launcher-runs-panel-item[aria-current='true'] { + background: rgb(255 255 255 / 72%); +} + +.launcher-runs-panel-item:disabled { + cursor: default; +} + +.launcher-runs-panel-name { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-runs-panel-meta, +.launcher-runs-panel-note { + color: var(--platform-text-muted); + font-size: 11px; +} + .launcher-promo { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx new file mode 100644 index 000000000..c075aaef3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; + +afterEach(() => cleanup()); + +it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); +}); + +it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); +}); diff --git a/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..df94a1580 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,51 @@ +# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md` + +## 固定契约 + +只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`): + +- `list_game_creator_direct_active_turns() -> Vec` +- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence` +- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed` + +身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。 + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试 +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表 +- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册 +- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载 +- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用) +- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试 +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正 + +## 修改顺序 + +1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。 +2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。 +3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。 +4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。 +5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整) +- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` +- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` +- 面板组件测试文件单独一条 vitest +- `npm --prefix apps/ai-game-creator-shell run typecheck` +- `npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。 +- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。 +- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。 +- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。 +- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。 diff --git a/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..b8ead65f3 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,33 @@ +# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」 + +## 目标 + +离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。 + +## 边界 + +- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。 +- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。 +- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。 +- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。 +- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。 + +## 验收标准 + +- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。 +- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。 +- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。 +- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。 +- 快照读取失败不得阻断发送、不得显示成业务失败。 +- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:` 仍显示审批提示。 + +## 未决事项 + +- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。 +- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。 +- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd8416d40..3fd4eeb46 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8759,4 +8759,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 +## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 +- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 +- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8d8af7f25..7d6c1ca0b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1398,3 +1398,11 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执 游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 + +## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性 + +Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。 + +壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。 + +活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。 From 8f456d974d3b9e31595fcd558081d4c9d9fc7725 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:35:16 +0800 Subject: [PATCH 49/49] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20Direct=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=9B=9E=E5=90=88=E6=9B=B4=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让活动回合快照在受理时即具备更新时间 保持面板与重连读取同一份完整快照 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index cb27517fe..01a63a6db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -319,6 +319,10 @@ impl DirectTaonierActiveInvocationGuard { }); } None => { + let started_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(); active.insert( root.clone(), DirectTaonierActiveInvocation { @@ -327,13 +331,10 @@ impl DirectTaonierActiveInvocationGuard { .file_name() .and_then(|name| name.to_str()) .map(str::to_string), - started_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + started_at, status: "accepted".to_string(), activity: Some("request-accepted".to_string()), - updated_at: 0, + updated_at: started_at, sequence: 0, }, );