Compare commits

..

35 Commits

Author SHA1 Message Date
lhk229 f5a333b839 Merge branch 'master' into feat/agc-canvas-acceptance
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
2026-09-18 01:04:04 +08:00
kdletters 2e15a70b14 Provider 瞬态重试次数严格按设置执行
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m38s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 8m4s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m19s
Project CI / AI game creator shell Rust crates (push) Successful in 2m13s
Project CI / Backend tests (push) Successful in 10m11s
Project CI / Frontend tests (push) Successful in 12m57s
Project CI / Repository checks (push) Successful in 15m23s
Project CI / AI game creator shell web tests (push) Successful in 9m18s
Project CI / Native shell tests (push) Successful in 22m24s
删除自主构建档位 12~16 的重试区间与 standard 档位 3 次上限,maxRetries 严格决定物理重试次数
上游 400 不再单独收窄到 2 次,与其它瞬态错误共用设置里的同一份预算
运行档位改为只决定上游 400 是否算瞬态,重试次数不再被档位改写
重写档位重试用例为设置值直取,同步 upstream-400 用例
decision-log 记录本次口径变更与本机既有失败边界
2026-09-18 00:01:40 +08:00
suzmii 20b1fd63de 合并最新主线抠图链路与开发监听修复
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m55s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m30s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m41s
Project CI / Native shell tests (pull_request) Successful in 15m5s
Project CI / Repository checks (pull_request) Successful in 16m54s
Project CI / Backend tests (pull_request) Successful in 19m49s
Project CI / Frontend tests (pull_request) Successful in 23m33s
Project CI / AI game creator shell web tests (pull_request) Successful in 11m6s
合入master的抠图账本链路、Vite监听边界及窗口测试更新

保留画布占位参考生成、资源展示与批量标签追加功能

合并态通过137项定向测试、AGC类型检查及Rust全目标编译
2026-09-17 22:18:23 +08:00
suzmii 105591bac5 支持画布与资源面板多选素材批量追加标签
复用标签编辑器并冻结整组选中素材,保留原标签与素材类型

原生批量校验后一次保存,覆盖权限、版本冲突与无变化边界

修复保存输入锁和切项目迟到回包,补齐混合选择入口原因

新增前后端回归并记录本地验证及待真实客户端验收事项
2026-09-17 21:51:59 +08:00
lhk229 195b7dd5dd 修复抠图401 404问题 (#394)
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m50s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m56s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m31s
Project CI / AI game creator shell Rust crates (push) Successful in 3m19s
Project CI / Native shell tests (push) Successful in 16m33s
Project CI / Backend tests (push) Successful in 17m56s
Project CI / Repository checks (push) Successful in 15m2s
Project CI / Frontend tests (push) Successful in 23m48s
Project CI / AI game creator shell web tests (push) Successful in 23m11s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/394
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-17 21:37:23 +08:00
suzmii da44d66dc8 明确多选素材批量追加标签的行为合同
限定追加语义、冻结对象、原子保存与类型保留边界

记录已评审里程碑及前后端并行实现计划
2026-09-17 21:12:42 +08:00
suzmii 0a85c4f87e 合并主线资源菜单和引擎预览功能
合入PR410的常用操作收纳与卡片信息类型入口

保留本分支资源名称、文档图标、占位生成与批量移动能力

整合引擎结构摘要与当前图集测试契约
2026-09-17 21:06:15 +08:00
suzmii efce7b102f 对齐主线图集契约与窗口同步测试要求
补齐图集切分声明与工具目录字段边界断言

更新原生HTTP权限守卫及稳定空快照发布次数期望

修正末尾空行并记录CI排查证据,保留待远程验证状态
2026-09-17 21:01:26 +08:00
suzmii db5edc948c 合并最新主分支并修复画布静态检查问题
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m30s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Failing after 4m28s
Project CI / Frontend tests (pull_request) Failing after 5m12s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Repository checks (pull_request) Failing after 5m43s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m34s
合入master的更新摘要、策划运行边界及图集契约调整

保留画布生成引用占位、资源卡展示与批量布局功能

修正导入顺序、未使用项和稳定回调依赖
2026-09-17 20:15:38 +08:00
suzmii 45c3780bf5 记录画布验收修复的验证证据与待验边界
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
登记自动化回归和独立评审结论

保留真实客户端与付费生成待验项及远程写入确认边界
2026-09-17 19:46:49 +08:00
suzmii 6fcf42e4ac 修 appSurface 生成任务用例的异步排空:等阶段合并、等轮询退出
- 阶段文案与徽标改用 waitFor:本地队列的「排队中。」先于后端记录合并,同步断言在整套连跑时抢跑
- 用例收尾连等两个 poll 周期(2s)并断言账本读计数不再增长,确认这条轮询链在本用例内退出
- 留在线上的轮询醒来会打进下一个用例的 spy(宿主按调用时读 __TAURI__),故排空放在用例内,未动全局 harness
- 未改生产:后台任务跨面板保活保持原样
2026-09-17 19:44:48 +08:00
suzmii e0f9f811b7 生成浮层居中定位与画布安全带高度
补回 transform translateX(-50%):锚点给的是占位卡中心,少了它浮层整体右偏半个面板宽
高度只按画布安全带分配:装得下时挂在卡下面,装不下时与卡顶边对齐盖住占位,不再压成一百多像素
reveal 的面板高度按当前缩放从屏幕 px 折回世界坐标,占位与浮层并集参与可见性
浮层宽度并入可见性并集,靠边卡片不再把浮层切掉一半
新增真实矩形模拟用例(画布高 568)核对浮层高度与落点不越出安全带
2026-09-17 19:35:41 +08:00
suzmii 03b5c1c9f4 补画布生成入口宿主生命周期验收:占位提交、同 operation 重试与成功落点
- 新增测试文件 resourceCanvasGenerationHostLifecycle.test.tsx,渲染真实 ProjectDevelopmentView
- 覆盖音频入口:点工具出占位、提交后占位进入 submitted(后台仍在跑时的中间态)
- 覆盖失败后原请求重试:复用同一 operationId 与幂等键,不会变成新的付费生成
- 覆盖成功后落点:结果卡按占位位置落盘(手动接管),占位随之被撤掉
- 原生命令全部走假实现,不触发任何真实 Provider 调用
2026-09-17 19:20:31 +08:00
suzmii 58992330aa 生成浮层面板 chrome 与画布安全高度修复
浮层复用模态的面板类:边框圆角底色内边距与 header 排布不再缺失
高度上界按真实画布可用底边算(画布高减退底栏安全区与顶边),空间紧时收紧并内部滚动
提交动作行固定在浮层底部,避免长表单把提交按钮顶到底栏下面
新增浮层高度模型测试、结构 CSS 钉子与浮层 chrome 断言
新增宿主级整合用例:图片按正式归类落点并用占位最新位置、音频失败保留与幂等重试
2026-09-17 19:08:34 +08:00
suzmii 2c687b01a4 补齐生成结果分类与占位交互验收合同
明确原生目标分类、音频幂等及画布浮层可见性
2026-09-17 19:08:34 +08:00
suzmii c0e377f479 测试助手同时支持纯文本与引用输入区
appSurface 图集/图标规范入口的提示词仍是 textarea,helper 不能只走 Lexical 编辑器 API
两种字段分别用原生 change 与编辑器 update,读回同样两种口径
补 targetCategory 随任务保存与恢复的断言
2026-09-17 19:01:35 +08:00
suzmii 6001b87215 画布生成按入口栏目落盘目标分类
GUI 生成命令新增可选 targetCategory:generate_local_project_asset 与 start_local_project_asset_generation 经 options 传入完成登记
PlatformArtAssetGenerationOptions 增加 target_category,Agent 与 Direct 路径保持 None,仍按 kind 派生默认分类
新建条目与同 kind 重登记都按显式目标分类写入 manifest 既有 category 字段,不新增字段、不改数据结构
目标分类只接受 GameCreationAppAssetCategory 的六个合法值,version 与 all 等栏目伪值在提交前失败关闭
target_category 刻意不进 standalone 指纹与 durable 请求快照,分类只影响本地 manifest 落点,避免在途账本换槽重复 POST,同任务幂等恢复由调用方继续用同一栏目提交
补用例:kind=image 带 targetCategory=character 落到 character 而不是 unclassified、非法栏目值不产生生成请求、登记入口的显式分类与不传时的原 kind 派生行为
2026-09-17 18:58:53 +08:00
suzmii b08ab6ee66 生成占位可见性、落点与音频提交身份修复
打开生成浮层时把占位与浮层整块带进安全区(避开顶栏底栏),只做最小视口平移不改缩放
落点意图改为按草稿 ID 的 Map,避免并发完成互相覆盖;坐标在落点时刻取占位最新位置
落点 section 改用资源正式归类后的 category,修复图片落待归类、规范落文档时静默跳过
音频与背景音乐提交绑定占位与 operationId:在途时拒绝再次提交,失败保留输入并可同 operation 重试
宿主记录收起浮层时的草稿与音频提交身份,重开占位接着编辑而不是回到空表单
生成任务新增入口栏目 targetCategory 走原生可选入参,前端不据此伪分类
补可见性平移的定向测试
2026-09-17 18:50:29 +08:00
suzmii 7f80012d7f 清理占位重开状态里不再读取的动作 ID
生成浮层的草稿、错误与占位归属统一按 draftId 判等,动作 ID 已无读取方,删掉避免留下死状态
2026-09-17 18:50:29 +08:00
suzmii fbe95591d5 校准布局撤销作用域的行为说明
明确切模式后新操作会替换旧撤销栈但不会跨写布局
2026-09-17 18:31:16 +08:00
suzmii d222aad2ec 参考素材改为提交前预检并补参考链路用例
参考解析前增加纯本地预检:清单归属、受控路径与文件、媒体类型、可解码位图全部通过后才允许产生任何上传副作用,避免第一张参考已上传、第二张坏图才失败
明确拒绝 SVG 等矢量参考,媒体类型或文件扩展名任一命中即拒,本次不做矢量转换,坏图同样在本地失败关闭
asset.upload 门禁提前到预检阶段,显式拒绝该命令的项目连第一个上传凭证都不会签发
预检与实际上传共用同一份本地判据 helper,不新建平行流程,上传侧仍按同一判据重新读取与解码
补参考合同与恢复的账本用例:icon-spec 带用户参考、game-background 规范图加用户参考、图集超限、空白引用写入失败
补提交侧用例:拒绝路径、未登记、非图片、缺失文件、超限、SVG 与坏图,并断言零上传凭证与零生成 POST,反向验证同一张合格参考单独提交确实会签发凭证
补 asset.upload 拒绝用例,以及清单校验与 Direct 身份校验接受用户参考、拒绝用户参考顶替规范引用的覆盖
2026-09-17 18:28:19 +08:00
suzmii 13b28ebbc7 生成占位与卡下独立浮层,修复引用选择被焦点陷阱阻断
工具点击先在当前栏目创建临时占位卡,生成浮层挂在占位下沿;图片、音效、背景音乐入口一致
占位按项目与草稿 ID 归属宿主临时状态:可拖动、提交绑定任务 ID、成功结果落到占位最新位置
删除占位只隐藏展示,关闭浮层不冒充取消后台任务;失败保留占位与输入引用供重试
生成面板改为非模态浮层:ThemedModal 焦点陷阱曾阻断 portal 到 body 的引用选择器,点选不生效
引用选择器定位加顶边钳制,搜索收窄与键盘选择可用;不再越出视口
参考候选与陈旧引用校验排除 SVG(原生解码不支持),其余 image 类型不额外收窄
任务创建处对参考资产 ID 去重;补浮层真实组件整合、占位模型与拖动、参考模型回归测试
2026-09-17 18:22:39 +08:00
suzmii 30648e6b93 画布修复:重算意图各占队列槽、撤销不跨排序模式、整理判据与落盘同源
- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理
- 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名
- 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去
- 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化
- 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY
- 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
2026-09-17 18:18:32 +08:00
suzmii 9dd1052374 明确参考图片格式与上传前整组校验边界
排除不可解码SVG参考并约束上传权限与账号归属判据
2026-09-17 18:18:32 +08:00
suzmii aec9568c39 接通画布生成参考素材的原生链路
新增 referenceAssetIds 原生入参:start_local_project_asset_generation 与 generate_local_project_asset 经 prepare_local_project_asset_generation 统一收口当前项目 manifest 图片素材 id
PlatformArtAssetGenerationOptions 增加 reference_asset_ids,GUI、agent 桥、media 观察与 Direct 运行时构造点显式传空
参考解析固定规范图前置在前、用户参考按给出顺序随后,按远端资源 ID 去重,普通生成总量 5 张、有规范前置 4 张用户参考,图集拒绝额外参考而非静默丢弃
参考素材复用 manifest 到本地文件、图片解码、内容 hash、当前账号 binding 上传的既有通道,历史账号遗留远端 ID 不再复用
retained 阶段与清单校验改为与提交同一套请求合同:icon-spec、ui-prototype、game-background 允许正确引用,art-spritesheet 仍只接受唯一规范引用
修复 Part::file_name 的生命周期错误与 4 处测试构造点少传参考参数
2026-09-17 17:57:53 +08:00
suzmii 0a1f0e0b26 画布布局收口:整理只动当前栏目、多选拖动一笔提交与一次撤销
- 整理画布重排目标栏目内全部素材(含手动坐标与被筛选隐藏的卡),「所有资源」页覆盖全部栏目
- 栏外坐标逐值保留:不再把其他栏目的自动坐标丢给重算,避免被顺带规整成规范槽位
- 手动标记纳入历史快照与相等判定,撤销整理恢复原坐标与原手动标记
- 新增批量坐标写入入口:多选拖动与撤销都只排一笔 CAS、只压一条历史
- 多选拖动按同一位移整批跟随,取消 / 捕获丢失 / 失焦回滚,切项目丢弃进行中的位移
- 补测试:栏外自动坐标保留、all 页整理范围、批量撤销与取消边界
2026-09-17 17:47:11 +08:00
suzmii 5c31ae91ca 返修资源卡完整名悬停入口并收短文档卡注释
- 完整资源名改挂在覆盖整卡、真正命中指针的选中按钮 title 上,名称条自身不再挂悬停不到的 title

- 无障碍名保持原有 aria-label 不变,长名仍是 CSS 单行省略

- 断言改为校验选中按钮 title,并钉住名称条不挂 title

- 文档卡分支注释只写当前行为,去掉旧实现说明
2026-09-17 17:37:48 +08:00
suzmii 3ba6168c6a 同步资源卡名称与画布布局交互产品合同
统一卡面名称和文档图标展示要求

明确当前栏目整理与多选移动的撤销边界
2026-09-17 17:15:38 +08:00
suzmii 2628b83d4b 画布资源卡统一显示正式资源名并简化文档卡卡面
- 资源卡新增卡面名称条,所有类型卡统一显示正式资源 label,长名单行省略、完整名进 title

- 文档卡卡面改为居中图标,不再把正文前几行铺在卡面上,正文只在独立详情浮层读取

- 删除卡面正文摘要的取文本 helper 与行数/长度常量,移除对应 CSS 规则不留墓碑

- 名称条按媒体卡让出右下播放钮位置,替换血缘角标上移避免遮住名称

- 新增资源卡名称与文档卡卡面用例,并同步重命名链路与卡面正文的既有断言
2026-09-17 17:08:42 +08:00
suzmii a7b2b0e23b 画布图片生成支持真实参考图引用
新增参考图模型:候选为当前项目已登记图片,用户参考上限普通生成 5 张、带规范前置 4 张、图集为 0
生成浮层复用资源引用输入组件,提示词里的 @ 引用直接产出当前 manifest 资产 ID 作为参考
生成任务与队列新增 referenceAssetIds,派发时以 referenceAssetIds 交给原生侧解析账号绑定
图集入口不呈现参考选择器,超限在提交前给出原因并挡住提交,不做静默截断
补充参考模型、面板接线与队列派发的定向测试
2026-09-17 16:59:49 +08:00
suzmii ef982fdb78 拆分原生引用与前端生成并行实现边界
使用独立工作树处理原生引用,固定引用资产ID接缝
2026-09-17 16:59:48 +08:00
suzmii 91b65f94ca 确保工作树测试读取本地共享组件源码
补齐共享包测试别名以避免复用依赖指向其他工作树

记录工作树依赖复用的解析检查要求
2026-09-17 16:18:17 +08:00
suzmii 70513cf049 按规范评审固定参考上限并放行实现
普通生成最多五张参考,图集拒绝额外参考

复用现有引用快照并核验恢复校验,明确里程碑验收边界
2026-09-17 16:09:14 +08:00
suzmii 87aed0b765 补齐画布任务身份与引用持久化边界
明确参考图片入参、账号绑定与恢复校验

固定栏目整理撤销、多选范围与素材名称来源
2026-09-17 16:06:57 +08:00
suzmii 733a7015af 明确画布验收修复范围与行为合同
统一生成占位与参考、资源卡展示和布局交互的验收要求

补充单里程碑并行实现计划和独立评审门禁
2026-09-17 16:02:37 +08:00
83 changed files with 13288 additions and 961 deletions
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -16,4 +16,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details.
After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.20",
"version": "2026-08-26.23",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
"sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475"
}
]
}
@@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity(
.to_string(),
reference_resource_ids: asset.source.reference_resource_ids.clone(),
};
// 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有
// 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。
let references_match_contract =
crate::agent::platform_art_runtime_references_match_request_contract(
&identity.reference_resource_ids,
expected_kind,
);
let lineage_matches = match expected_reference_source {
Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity),
None => identity.reference_resource_ids.is_empty(),
None => true,
};
lineage_matches.then_some(identity)
(references_match_contract && lineage_matches).then_some(identity)
})
}
@@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source(
source: &DirectTaonierArtAssetIdentity,
derived: &DirectTaonierArtAssetIdentity,
) -> bool {
let [remote_reference_id] = derived.reference_resource_ids.as_slice() else {
// 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后,
// 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。
let Some(remote_reference_id) = derived.reference_resource_ids.first() else {
return false;
};
if derived.canvas_project_id == source.canvas_project_id
@@ -3390,6 +3399,8 @@ async fn generate_direct_taonier_art_asset_at(
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -9802,6 +9813,78 @@ mod tests {
);
}
#[test]
fn direct_taonier_art_package_accepts_manifest_user_references() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考")
.expect("init project");
register_direct_taonier_art_package_fixture(root.path());
assert!(direct_taonier_art_package_is_valid(root.path()));
// 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。
mutate_manifest_at(root.path(), |manifest| {
let art_spec = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH)
.expect("art spec asset");
art_spec.source.reference_resource_ids = vec![
"user-reference-1".to_string(),
"user-reference-2".to_string(),
];
let background = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH)
.expect("background asset");
background
.source
.reference_resource_ids
.push("user-reference-1".to_string());
Ok(())
})
.expect("apply user references to the art base");
assert!(
direct_taonier_art_package_is_valid(root.path()),
"user references must not invalidate the art package"
);
// 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。
mutate_manifest_at(root.path(), |manifest| {
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
.expect("spritesheet asset");
spritesheet
.source
.reference_resource_ids
.push("user-reference-1".to_string());
Ok(())
})
.expect("add an extra spritesheet reference");
assert!(
!direct_taonier_art_package_is_valid(root.path()),
"art spritesheet must reject extra user references"
);
// 用户参考不能顶替图集的规范前置。
mutate_manifest_at(root.path(), |manifest| {
let spritesheet = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
.expect("spritesheet asset");
spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()];
Ok(())
})
.expect("replace the spritesheet canonical reference");
assert!(
!direct_taonier_art_package_is_valid(root.path()),
"a user reference must not replace the art spritesheet canonical spec"
);
}
#[test]
fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1294,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
.map(|asset| bridge_registered_resource(asset, include_sequence_frames))
.collect::<Vec<_>>();
let next_offset = (offset + resources.len() < total).then_some(offset + resources.len());
let pending = list_pending_local_project_resource_edits_at(
let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount)
.then(current_platform_session)
.flatten();
let pending = list_pending_local_project_resource_edits_for_session_at(
ListPendingLocalProjectResourceEditsInput {
project_path: root
.to_str()
@@ -1302,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
.to_string(),
expected_project_id: manifest.project_id,
},
platform_session.as_ref(),
)?
.into_iter()
.map(|edit| {
@@ -1311,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
"mode": edit.generation_mode,
"sourceResourceId": edit.source_resource_id,
"assetName": edit.asset_name,
"backgroundMode": edit.background_mode,
"screenColor": edit.screen_color,
"phase": edit.phase,
"createdAt": edit.created_at,
})
@@ -1801,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments:
fn bridge_completed_resource_result(
root: &Path,
kind: DirectResourceGenerationKind,
mode: DirectResourceGenerationMode,
kind: &str,
mode: &str,
result: DeriveLocalProjectResourceResult,
) -> Result<Value, String> {
let asset = result
@@ -1814,8 +1820,8 @@ fn bridge_completed_resource_result(
Ok(json!({
"status": "completed",
"operationId": result.operation_id,
"kind": kind.as_str(),
"mode": mode.as_str(),
"kind": kind,
"mode": mode,
"sourceResourceId": result.source_resource_id,
"committedProjectRevision": result.committed_project_revision,
"resource": bridge_registered_resource(asset, true),
@@ -1910,10 +1916,17 @@ async fn bridge_create_or_derive_resource(
source_version_id: None,
prompt: input.prompt.clone(),
asset_name: input.asset_name.clone(),
background_mode: None,
screen_color: None,
};
with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await?
};
bridge_completed_resource_result(&state.root, input.kind, input.mode, completed)
bridge_completed_resource_result(
&state.root,
input.kind.as_str(),
input.mode.as_str(),
completed,
)
}
.await;
match result {
@@ -1927,7 +1940,8 @@ async fn bridge_create_or_derive_resource(
}
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
let _generation_guard = state.resource_generation_gate.lock().await;
let result = with_direct_editor_api_credentials(async {
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?;
@@ -1948,74 +1962,72 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
if !source_asset.media_type.starts_with("image/") {
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
}
let source_resource_id = source_asset
.source
.resource_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && !value.starts_with("local-asset:"))
.ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())?
.to_string();
let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?;
let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?;
let client = crate::http_client::agc_main_site_client_builder()
.build()
.map_err(|_| "创建抠图服务连接失败".to_string())?;
let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let background_mode = background_mode.unwrap_or("complex").to_string();
let source_resource_id = bridge_asset_canonical_resource_id(source_asset);
let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
Some(background_mode.as_str()),
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?;
let pending = list_pending_local_project_resource_edits_for_session_at(
ListPendingLocalProjectResourceEditsInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
},
platform_session.as_ref(),
)?;
let matching_pending = pending
.into_iter()
.filter(|pending| {
pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval
&& (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str())
|| pending.source_resource_id == format!("local-asset:{source_asset_id}"))
&& pending.asset_name == asset_name
&& pending.background_mode.as_deref().unwrap_or("complex")
== background_mode.as_str()
&& pending.screen_color.as_deref() == screen_color
})
.collect::<Vec<_>>();
if matching_pending.len() > 1 {
return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string());
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&request_body),
)
.send()
.await
.map_err(|error| format!("抠图服务提交失败:{error}"))?;
let status = response.status();
let payload = response
.json::<Value>()
.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();
Ok::<_, String>(json!({
"status": "queued",
"sourceLocalAssetId": source_asset_id,
"assetName": asset_name,
"projectId": manifest.project_id,
"assetFolderId": context.asset_folder_id,
"queueState": bridge_safe_queue_state(queue_state),
}))
}
let completed = if let Some(pending) = matching_pending.into_iter().next() {
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
operation_id: pending.operation_id,
})
.await?
} else {
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
let request = DeriveLocalProjectResourceInput {
project_path: state.root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
expected_project_revision: revision,
operation_id,
idempotency_key,
edit_kind: LocalProjectResourceEditKind::BackgroundRemoval,
generation_mode: LocalProjectResourceGenerationMode::Derive,
source_resource_id,
source_asset_id: Some(source_asset_id.clone()),
source_path: Some(source_asset.local_path.clone()),
source_media_type: Some(source_asset.media_type.clone()),
source_subtype: Some(source_asset.kind.clone()),
producer_task_id: source_asset.source.task_id.clone(),
source_version_id: None,
prompt: "去除背景".to_string(),
asset_name: asset_name.clone(),
background_mode: Some(background_mode),
screen_color: screen_color.map(str::to_string),
};
derive_local_project_resource_at(request).await?
};
emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal");
bridge_completed_resource_result(&state.root, "background-removal", "derive", completed)
})
.await;
match result {
Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false),
@@ -2041,17 +2053,6 @@ fn background_removal_request_fingerprint(
}
}
fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object();
json!({
"operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str),
"status": object.and_then(|value| value.get("status")).and_then(Value::as_str),
"phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str),
"progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64),
"updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64),
})
}
fn bridge_art_resources(
root: &Path,
asset_paths: &[String],
@@ -2340,6 +2341,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
slice_mode,
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -3844,20 +3847,4 @@ mod tests {
);
}
}
#[test]
fn bridge_background_removal_queue_projection_is_bounded() {
let projection = bridge_safe_queue_state(json!({
"operationId": "background-removal-1",
"status": "queued",
"phaseLabel": "排队中",
"progress": 0,
"updatedAtMicros": 1,
"error": "private provider detail",
"signedUrl": "https://private.invalid/result"
}));
assert_eq!(projection["operationId"], "background-removal-1");
assert!(projection.get("error").is_none());
assert!(projection.get("signedUrl").is_none());
}
}
@@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{
generate_platform_art_asset_with_options_at,
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
PLATFORM_ART_ASSET_GENERATION_KINDS,
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
File diff suppressed because it is too large Load Diff
@@ -416,10 +416,6 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50;
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000;
pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str =
"agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation";
pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16;
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4;
pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000;
pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600;
@@ -84,7 +84,7 @@ pub(crate) use response_stream::{
pub(crate) use run_configuration::{
agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at,
game_creator_agent_runtime_project_revision_path,
game_creator_agent_runtime_provider_transient_max_retries_at,
game_creator_agent_runtime_provider_transient_retry_policy_at,
game_creator_agent_runtime_run_profile_binding_path,
read_game_creator_agent_runtime_run_profile_binding,
};
@@ -717,14 +717,14 @@ where
Fut: std::future::Future<Output = Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse,
{
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
provider_snapshot,
llm,
@@ -1365,17 +1365,9 @@ where
)?;
return Err("Provider 瞬态错误编码损坏".to_string());
};
let error_max_retries = if error_kind == "upstream-400" {
effective_max_retries
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
} else {
effective_max_retries
};
if existing
.as_ref()
.is_some_and(|record| record.max_retries != error_max_retries)
|| attempt >= error_max_retries
{
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = effective_max_retries;
if attempt >= error_max_retries {
crate::provider_retry::remove_at(
root,
&provider_snapshot.agent_id,
@@ -1517,14 +1509,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
operation: &str,
request: &LlmRunRequest,
) -> Result<Option<platform_llm::LlmRunResponse>, String> {
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
for attempt in 0..=max_retries {
let request_slot = if attempt == 0 {
provider_snapshot.request_slot.clone()
@@ -1585,11 +1577,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
let Some((error_kind, public_error)) = encoded.split_once('\n') else {
return Err("Provider 瞬态错误编码损坏".to_string());
};
let error_max_retries = if error_kind == "upstream-400" {
max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
} else {
max_retries
};
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = max_retries;
if attempt >= error_max_retries {
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
public_error,
@@ -395,12 +395,22 @@ pub(crate) fn agent_runtime_run_profile_identity_at(
Ok((profile, String::new()))
}
pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
/// 当前持久 run 的 Provider 瞬态重试策略。
///
/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间,
/// 只决定上游 400 是否算瞬态错误。
#[derive(Debug)]
pub(crate) struct AgentRuntimeProviderTransientRetryPolicy {
pub(crate) max_retries: u32,
pub(crate) retry_upstream_400: bool,
}
pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
root: &Path,
agent_id: &str,
run_id: &str,
configured_max_retries: u32,
) -> Result<u32, String> {
) -> Result<AgentRuntimeProviderTransientRetryPolicy, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
let stored_identity =
read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)?
@@ -416,10 +426,8 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
stored_profile,
stored_binding_fingerprint,
)?;
if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
return Ok(configured_max_retries
.max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR)
.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT));
}
Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT))
Ok(AgentRuntimeProviderTransientRetryPolicy {
max_retries: configured_max_retries,
retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
})
}
@@ -577,6 +577,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
};
if let Some(pending) = pending_action {
match recover_persisted_visual_generation_options(
@@ -628,6 +630,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
grid_x,
grid_y,
reference_asset_ids: requested_options.reference_asset_ids,
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
target_category: requested_options.target_category,
}
};
options.replace_existing = replace_existing;
@@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
target_category: Option<String>,
) -> Result<AssetGenerationTaskRecord, String> {
let task_id = asset_generation_task_id(&task_id)?;
let request = prepare_local_project_asset_generation(
@@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -1,5 +1,6 @@
use super::*;
use sha2::{Digest as _, Sha256};
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::future::Future;
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
@@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
generation_kind: None,
reference_resource_ids: Vec::new(),
},
None,
)?;
changed |= asset_changed;
}
@@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
.map(|(result, _)| result)
register_local_asset_entry_with_change(
root, local_path, kind, media_type, id_prefix, source, None,
)
.map(|(result, _)| result)
}
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
///
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`]
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
/// [`register_local_asset_entry`],行为不变。
pub(crate) fn register_local_asset_entry_with_category(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
let target_category = normalize_asset_category_override(target_category)?;
register_local_asset_entry_with_change(
root,
local_path,
kind,
media_type,
id_prefix,
source,
target_category,
)
.map(|(result, _)| result)
}
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
fn normalize_asset_category_override(
target_category: Option<&str>,
) -> Result<Option<GameCreationAppAssetCategory>, String> {
let Some(target_category) = target_category else {
return Ok(None);
};
let target_category = target_category.trim();
if target_category.is_empty() {
return Ok(None);
}
game_creation_app_asset_category_from_str(target_category)
.map(Some)
.ok_or_else(|| format!("非法资源分类:{target_category}"))
}
fn register_local_asset_entry_with_change(
@@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change(
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<GameCreationAppAssetCategory>,
) -> Result<(UploadLocalAssetResult, bool), String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
@@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change(
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
let changed = existing.kind != kind
|| existing.media_type != media_type
|| existing.source != source;
|| existing.source != source
|| target_category.is_some_and(|category| existing.category != category);
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
}
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
if let Some(category) = target_category {
existing.category = category;
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update", changed))
@@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
category: target_category
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
tags: Vec::new(),
source,
});
@@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at(
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::{Read, Write};
#[test]
@@ -2176,6 +2235,121 @@ mod tests {
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
}
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
///
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
#[test]
fn explicit_target_category_overrides_the_kind_derived_category() {
fn canvas_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
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(),
}
}
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("registered asset is present")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
.expect("init project");
fs::create_dir_all(root.join("assets")).expect("create assets dir");
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
let registered = register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("character"),
)
.expect("register with a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::Character
);
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("ui-interaction"),
)
.expect("re-register with another target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 非法值失败关闭,且不动已落盘的分类。
assert!(register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("version"),
)
.is_err());
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
let plain = register_local_asset_entry(
root,
"assets/plain.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("register without a target category");
assert_eq!(
category_of(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
register_local_asset_entry(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("re-register without a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
@@ -2320,6 +2320,27 @@ pub(crate) fn update_local_project_resource_classification(
)
}
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
#[tauri::command]
pub(crate) fn add_local_project_resource_tags(
input: AddLocalProjectResourceTagsInput,
) -> Result<AddLocalProjectResourceTagsResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
add_manifest_asset_tags_at(
root,
&input.expected_project_id,
input.expected_project_revision,
input.asset_ids,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -4871,6 +4892,8 @@ pub(crate) fn prepare_local_project_asset_generation(
image_size: Option<&str>,
asset_name: Option<&str>,
output_path: Option<&str>,
reference_asset_ids: &[String],
target_category: Option<&str>,
) -> Result<LocalProjectAssetGenerationRequest, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
@@ -4878,6 +4901,13 @@ pub(crate) fn prepare_local_project_asset_generation(
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
let reference_asset_ids =
normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?;
// GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类
// 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。
let target_category = normalize_platform_art_target_category(target_category)?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
@@ -4914,6 +4944,8 @@ pub(crate) fn prepare_local_project_asset_generation(
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
reference_asset_ids,
target_category,
},
})
}
@@ -4935,6 +4967,10 @@ pub(crate) async fn generate_local_project_asset(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类,
// 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。
target_category: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let request = prepare_local_project_asset_generation(
&project_path,
@@ -4944,6 +4980,8 @@ pub(crate) async fn generate_local_project_asset(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -4962,7 +5000,17 @@ mod local_project_asset_generation_tests {
use super::*;
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
prepare_local_project_asset_generation(
"/tmp/project",
kind,
prompt,
None,
None,
None,
None,
&[],
None,
)
}
#[test]
@@ -5002,6 +5050,8 @@ mod local_project_asset_generation_tests {
Some("2K"),
Some(" 主角图集 "),
Some(" assets/hero.png "),
&[],
None,
)
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
@@ -5029,8 +5079,18 @@ mod local_project_asset_generation_tests {
#[test]
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
assert_eq!(
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
.expect_err("empty project path"),
prepare_local_project_asset_generation(
"",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect_err("empty project path"),
"项目路径不能为空"
);
assert_eq!(
@@ -5041,6 +5101,60 @@ mod local_project_asset_generation_tests {
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
);
// 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。
for rejected in ["version", "all", "bogus", "UI"] {
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(rejected),
)
.expect_err("illegal target category"),
format!("目标分类不是合法素材分类:{rejected}")
);
}
// 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(" ui-interaction "),
)
.expect("legal target category")
.options
.target_category
.as_deref(),
Some("ui-interaction")
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect("omitted target category")
.options
.target_category,
None
);
assert_eq!(
prepare(
"spec",
@@ -5057,7 +5171,9 @@ mod local_project_asset_generation_tests {
Some("4:3"),
None,
None,
None
None,
&[],
None,
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
@@ -5070,7 +5186,9 @@ mod local_project_asset_generation_tests {
None,
Some("4K"),
None,
None
None,
&[],
None,
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
@@ -5083,7 +5201,9 @@ mod local_project_asset_generation_tests {
None,
None,
Some("\u{7}名字"),
None
None,
&[],
None,
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
@@ -5096,7 +5216,9 @@ mod local_project_asset_generation_tests {
None,
None,
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)),
&[],
None,
)
.expect_err("oversized output path"),
"输出路径超出安全边界"
@@ -2589,6 +2589,7 @@ fn main() {
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
}
if task_id == "art-director" {
if !asset.source.reference_resource_ids.is_empty() {
return Err("统一视觉规范图不得声明派生资源引用".to_string());
// 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考
// (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证
// 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId /
// resourceId / PNG 解码等身份判据全部保持不变。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
return Err(format!(
"统一视觉规范图的参考集合不符合请求合同:{expected_path}"
));
}
return Ok(());
}
@@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
// 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考:
// 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
return Err(format!(
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
));
};
}
let reference_resource_id = asset.source.reference_resource_ids[0].as_str();
let original_provenance_matches =
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
let rebound_local_source_matches = if original_provenance_matches {
@@ -1328,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_ids: Vec<String>,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsResult {
pub(crate) assets: Vec<GameCreationAppAssetManifestEntry>,
pub(crate) committed_project_revision: u64,
}
/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。
/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。
pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200;
/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。
///
/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍:
///
/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了
/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写;
/// - 空批次失败;
/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描),
/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。
fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result<Vec<String>, String> {
let mut normalized: Vec<String> = Vec::new();
for asset_id in asset_ids {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("批量标签 assetId 不能为空".to_string());
}
if !normalized.iter().any(|existing| existing == asset_id) {
normalized.push(asset_id.to_string());
if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS {
return Err(format!(
"批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材"
));
}
}
}
if normalized.is_empty() {
return Err("批量标签至少需要一个素材".to_string());
}
Ok(normalized)
}
/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用
/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。
///
/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘,
/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。
fn normalize_manifest_batch_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_manifest_asset_tags(tags)?;
if normalized.is_empty() {
return Err("批量标签不能为空".to_string());
}
Ok(normalized)
}
/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。
/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。
fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec<String> {
let mut merged = existing.to_vec();
for tag in incoming {
if !merged.iter().any(|current| current == tag) {
merged.push(tag.clone());
}
}
merged
}
/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。
struct ManifestAssetTagAppendPlan {
/// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。
assets: Vec<GameCreationAppAssetManifestEntry>,
/// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。
updates: Vec<(usize, Vec<String>)>,
/// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。
changed_asset_ids: Vec<String>,
}
/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。
///
/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误;
/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。
fn plan_manifest_asset_tag_append(
manifest: &GameCreationAppManifest,
asset_ids: &[String],
tags: &[String],
) -> Result<ManifestAssetTagAppendPlan, String> {
let mut assets = Vec::with_capacity(asset_ids.len());
let mut updates: Vec<(usize, Vec<String>)> = Vec::with_capacity(asset_ids.len());
let mut changed_asset_ids = Vec::new();
for asset_id in asset_ids {
let index = manifest
.assets
.iter()
.position(|asset| &asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let asset = &manifest.assets[index];
// 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。
// 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点**
// 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。
let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags))
.map_err(|error| {
format!(
"素材 {}{})的标签合并结果不合法:{error};本次未写入任何素材",
asset.id, asset.local_path
)
})?;
if merged != asset.tags {
changed_asset_ids.push(asset.id.clone());
}
updates.push((index, merged.clone()));
assets.push(GameCreationAppAssetManifestEntry {
tags: merged,
..asset.clone()
});
}
Ok(ManifestAssetTagAppendPlan {
assets,
updates,
changed_asset_ids,
})
}
/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、
/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前),
/// 但作用域是**整批**
///
/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS
/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**;
/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision;
/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。
///
/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威
/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。
pub(crate) fn add_manifest_asset_tags_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_ids: Vec<String>,
tags: Vec<String>,
) -> Result<AddLocalProjectResourceTagsResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("批量标签 expectedProjectId 不能为空".to_string());
}
let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?;
let tags = normalize_manifest_batch_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
// 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径);
// 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。
let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
// no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。
// 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。
let plan = plan_manifest_asset_tag_append(
&read_existing_manifest_for_project(root)?,
&asset_ids,
&tags,
)?;
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
let plan = mutate_manifest_at(root, |manifest| {
// 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。
// 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿
// 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。
let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?;
for (index, merged) in &plan.updates {
manifest.assets[*index].tags = merged.clone();
}
Ok(plan)
})?;
// 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容,
// 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE,
"assetIds": plan.changed_asset_ids,
"expectedProjectRevision": expected_project_revision,
"appendedTags": tags,
}),
)
.map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?;
Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision,
})
}
/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。
pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append";
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_
}
#[test]
fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -671,16 +671,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
)
.expect("project init");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
"design-director",
"legacy-standard-run",
99,
)
.expect("legacy standard retry policy"),
3
);
// 历史 standard run 仍走同一身份校验,但重试次数不再被收进区间。
let legacy_standard = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
"design-director",
"legacy-standard-run",
99,
)
.expect("legacy standard retry policy");
assert_eq!(legacy_standard.max_retries, 99);
assert!(!legacy_standard.retry_upstream_400);
let standard = bind_game_creator_agent_runtime_run_profile_at(
&root,
"design-director",
@@ -691,25 +691,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
)
.expect("bind standard profile");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&standard.agent_id,
&standard.run_id,
0,
)
.expect("standard zero retry policy"),
.expect("standard zero retry policy")
.max_retries,
0
);
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&standard.agent_id,
&standard.run_id,
99,
)
.expect("standard capped retry policy"),
3
);
let standard_configured = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&standard.agent_id,
&standard.run_id,
99,
)
.expect("standard configured retry policy");
assert_eq!(standard_configured.max_retries, 99);
assert!(!standard_configured.retry_upstream_400);
let parent = bind_game_creator_agent_runtime_run_profile_at(
&root,
@@ -720,17 +720,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
None,
)
.expect("bind autonomous parent profile");
for (configured, expected) in [(0, 12), (14, 14), (99, 16)] {
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&parent.agent_id,
&parent.run_id,
configured,
)
.expect("autonomous parent retry policy"),
expected
);
for (configured, expected) in [(0, 0), (5, 5), (99, 99)] {
let policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&parent.agent_id,
&parent.run_id,
configured,
)
.expect("autonomous parent retry policy");
assert_eq!(policy.max_retries, expected);
assert!(policy.retry_upstream_400);
}
let child_link = AgentRuntimeTaskLink {
@@ -758,14 +757,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
append_game_creator_agent_runtime_task(&root, &child_state)
.expect("append autonomous child task projection");
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
0,
)
.expect("autonomous child retry policy"),
12
.expect("autonomous child retry policy")
.max_retries,
0
);
assert!(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
0,
)
.expect("autonomous child retry policy")
.retry_upstream_400
);
fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
@@ -775,7 +785,7 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
))
.expect("remove autonomous child binding");
assert!(
game_creator_agent_runtime_provider_transient_max_retries_at(
game_creator_agent_runtime_provider_transient_retry_policy_at(
&root,
&child.agent_id,
&child.run_id,
@@ -4558,7 +4568,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() {
}
#[tokio::test]
async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() {
async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试")
.expect("project init");
@@ -4631,7 +4641,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
"model": "supervisor-autonomous-upstream-400-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 0,
"maxRetries": 2,
"retryBackoffMs": 1
}}
}}
@@ -4676,10 +4686,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
.expect("initial autonomous upstream 400 request");
assert_eq!(waiting.error_kind, "upstream-400");
assert_eq!(waiting.next_attempt, 1);
assert_eq!(
waiting.max_retries,
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
assert_eq!(waiting.max_retries, 2);
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
.expect("force autonomous upstream 400 retry due");
@@ -4725,10 +4732,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b
.collect::<Vec<_>>();
assert_eq!(retry_audits.len(), 1);
assert_eq!(retry_audits[0]["errorKind"], "upstream-400");
assert_eq!(
retry_audits[0]["maxRetries"],
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
assert_eq!(retry_audits[0]["maxRetries"], 2);
let lifecycle = records
.iter()
.filter(|record| {
@@ -6923,6 +6927,22 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"sliceCount"
])
);
let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"];
assert_eq!(
canvas_properties["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
for field in ["gridX", "gridY", "sliceCount"] {
assert_eq!(
canvas_properties[field]["type"],
serde_json::json!(["integer", "null"])
);
assert_eq!(canvas_properties[field]["minimum"], 1);
assert_eq!(
canvas_properties[field]["maximum"],
if field == "sliceCount" { 256 } else { 32 }
);
}
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"],
serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null])
@@ -495,8 +495,9 @@ function ResourceReferenceEditor({
useState<ResourceReferenceScope | null>(null);
const [pickerPosition, setPickerPosition] = useState<{
left: number;
bottom: number;
top: number;
width: number;
maxHeight: number;
} | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
@@ -960,18 +961,46 @@ function ResourceReferenceEditor({
const updatePickerPosition = useCallback(() => {
const rect = rootRef.current?.getBoundingClientRect();
if (!rect) return;
const viewportPadding = 12;
const gap = 8;
const width = Math.min(
Math.max(rect.width, 360),
Math.max(280, window.innerWidth - 24),
Math.max(280, window.innerWidth - viewportPadding * 2),
);
const left = Math.min(
Math.max(12, rect.left),
Math.max(12, window.innerWidth - width - 12),
Math.max(viewportPadding, rect.left),
Math.max(viewportPadding, window.innerWidth - width - viewportPadding),
);
/**
* 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。
*
* 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px,
* 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排
* 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间,
* 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。
*/
const availableAbove = Math.max(0, rect.top - viewportPadding - gap);
const availableBelow = Math.max(
0,
window.innerHeight - rect.bottom - viewportPadding - gap,
);
const openAbove =
availableAbove >= 200 || availableAbove >= availableBelow;
const maxHeight = Math.max(
160,
Math.min(480, openAbove ? availableAbove : availableBelow),
);
const top = openAbove
? Math.max(viewportPadding, rect.top - gap - maxHeight)
: Math.min(
Math.max(viewportPadding, window.innerHeight - viewportPadding - maxHeight),
rect.bottom + gap,
);
setPickerPosition({
left,
bottom: Math.max(12, window.innerHeight - rect.top + 8),
top,
width,
maxHeight,
});
}, []);
@@ -1096,10 +1125,15 @@ function ResourceReferenceEditor({
aria-modal="false"
aria-label="选择素材"
style={{
// 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在
// 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。
position: 'fixed',
top: `${pickerPosition.top}px`,
left: `${pickerPosition.left}px`,
bottom: `${pickerPosition.bottom}px`,
right: 'auto',
bottom: 'auto',
width: `${pickerPosition.width}px`,
maxHeight: `${pickerPosition.maxHeight}px`,
}}
>
<header>
@@ -1,12 +1,31 @@
import './resourceCanvasGenerationPanel.css';
import { Sparkles, X } from 'lucide-react';
import { type FormEvent, useState } from 'react';
import { type CSSProperties, type FormEvent, useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput';
import type {
ChatComposerDraft,
ChatReference,
} from '../project-workspace/resourceReferences';
import {
resourceCanvasAssetGenerationAcceptsReferences,
resourceCanvasAssetGenerationReferenceAssets,
resourceCanvasAssetGenerationReferenceError,
resourceCanvasAssetGenerationReferenceIds,
resourceCanvasAssetGenerationReferenceIssue,
resourceCanvasAssetGenerationUserReferenceLimit,
} from './resourceCanvasAssetGenerationReferenceModel';
import {
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
@@ -20,6 +39,14 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
assetName: string;
aspectRatio: string;
imageSize: string;
/**
* 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。
*
* 宿主从这里取 `resourceId`**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧;
* 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里
* 历史账号的远端 ID。
*/
references: ChatReference[];
};
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
@@ -28,6 +55,8 @@ export type ResourceCanvasAssetGenerationPanelDraft = {
assetName: string;
aspectRatio: string;
imageSize: string;
/** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */
references: ChatReference[];
};
export type ResourceCanvasAssetGenerationPanelViewProps = {
@@ -40,6 +69,25 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
draft?: ResourceCanvasAssetGenerationPanelDraft;
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
error?: string | null;
/** 当前项目的 manifest 资产:参考选择的候选集由它收口到本项目的已登记图片。 */
assets?: readonly GameCreationAppAssetManifestEntry[];
/** `@` 引用选择器需要项目路径来登记资源预览,与快速编辑走同一条链路。 */
projectPath?: string;
versions?: GameIterationVersion[];
activeVersionId?: string | null;
/**
* 呈现形态。
*
* `modal`:既有居中弹层(`ThemedModal` + 焦点陷阱)。`floating`:挂在画布占位卡下沿的
* **独立浮层**——工具点击先建占位,浮层只是它旁边的一块 UI。
*
* 生成浮层必须走 `floating``ThemedModal` 的焦点陷阱会把 `@` 引用选择器(portal 到 body 的
* `resource-reference-picker`)挡在陷阱之外,候选项点了不生效。浮层不是模态,因此不受这条限制;
* 「关闭浮层不等于取消后台任务」的语义也由浮层形态直接成立。
*/
variant?: 'modal' | 'floating';
/** 浮层形态的定位样式(贴着占位卡下沿,与快速编辑 / 信息浮层同一条锚点口径)。 */
style?: CSSProperties | null;
/**
* 提交回调:**同步返回**,面板不等它的结果。
*
@@ -47,7 +95,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
* 面板自己不持有任何在途状态。
*/
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
onClose: () => void;
/**
* 收起浮层。
*
* 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。
* 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。
*/
onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void;
};
/**
@@ -73,10 +127,19 @@ export function ResourceCanvasAssetGenerationPanelView({
action,
draft,
error: initialError,
assets,
projectPath,
versions,
activeVersionId,
variant = 'modal',
style,
onSubmit,
onClose,
}: ResourceCanvasAssetGenerationPanelViewProps) {
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
const [references, setReferences] = useState<ChatReference[]>(
draft?.references ?? [],
);
const [assetName, setAssetName] = useState(
draft?.assetName ?? action.assetName,
);
@@ -90,13 +153,71 @@ export function ResourceCanvasAssetGenerationPanelView({
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
/**
* 参考选择只对有真实参考能力的入口呈现。
*
* 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器,
* 原生侧同样拒绝额外参考(不是静默丢弃)。
*/
const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action);
const referenceLimit =
resourceCanvasAssetGenerationUserReferenceLimit(action);
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
assets ?? [],
);
const referenceAssetIds =
resourceCanvasAssetGenerationReferenceIds(references);
const referenceError = resourceCanvasAssetGenerationReferenceError({
action,
referenceCount: referenceAssetIds.length,
});
/*
陈旧引用(素材被删 / 改了类型 / 没有本地文件)必须在提交前报出来:过滤掉再提交等于
把「带参考」变成「无参考」的付费生成,用户还以为参考生效了。
*/
const referenceIssue = referenceEnabled
? resourceCanvasAssetGenerationReferenceIssue({
references,
assets: assets ?? [],
})
: null;
const promptTooLong = prompt.trim().length > promptMaxLength;
const promptTooLongError = promptTooLong
? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length}`
: null;
const canSubmit =
prompt.trim().length > 0 &&
assetName.trim().length > 0 &&
!referenceError &&
!referenceIssue &&
!promptTooLong;
const shownError = error ?? referenceIssue ?? promptTooLongError ?? referenceError;
const applyDraft = (next: ChatComposerDraft) => {
setPrompt(next.text);
setReferences(next.references);
};
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
const closeWithDraft = () =>
onClose({
prompt,
assetName,
aspectRatio,
imageSize,
references,
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedAssetName = assetName.trim();
if (!normalizedPrompt || !normalizedAssetName) {
// 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。
if (
!normalizedPrompt ||
!normalizedAssetName ||
referenceError ||
referenceIssue ||
promptTooLong
) {
return;
}
setError(null);
@@ -108,17 +229,14 @@ export function ResourceCanvasAssetGenerationPanelView({
assetName: normalizedAssetName,
aspectRatio,
imageSize,
references,
});
// 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。
onClose();
}
return (
<ThemedModal
open
ariaLabel={action.label}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
const panelBody = (
<>
<header>
<div>
<h2>{action.label}</h2>
@@ -126,7 +244,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
onClick={onClose}
onClick={closeWithDraft}
>
<X size={16} aria-hidden="true" />
</button>
@@ -143,16 +261,40 @@ export function ResourceCanvasAssetGenerationPanelView({
</label>
<label>
<span></span>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
{referenceEnabled ? (
/*
与聊天输入区、资源快速编辑同一个 `@` 引用输入区:候选、`@显示名` 文本与引用模型都
复用那一份,所以参考图带的是**稳定资源 ID**(进而出站到当前账号绑定下的远端资源),
不是只有名字的纯提示词。候选集只放当前项目的已登记图片。
*/
<div className="resource-canvas-asset-generation-prompt-input">
<ResourceReferenceInput
ariaLabel="生成提示词"
value={prompt}
references={references}
onChange={applyDraft}
assets={referenceAssets}
projectPath={projectPath ?? ''}
versions={versions}
activeVersionId={activeVersionId}
multiline
rows={6}
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
showPolishAction={false}
/>
</div>
) : (
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
)}
</label>
{action.adjustableDimensions ? (
<div className="resource-canvas-asset-generation-dimensions">
@@ -197,16 +339,26 @@ export function ResourceCanvasAssetGenerationPanelView({
prompt={prompt}
applyPrompt={setPrompt}
/>
{error ? (
{referenceEnabled && referenceLimit > 0 ? (
<p
className="resource-canvas-asset-generation-reference-hint"
data-resource-canvas-generation-reference-count={
referenceAssetIds.length
}
>
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
</p>
) : null}
{shownError ? (
<p className="game-resource-generation-error" role="alert">
{error}
{shownError}
</p>
) : null}
<div className="game-resource-generation-actions">
<PlatformActionButton
type="button"
tone="secondary"
onClick={onClose}
onClick={closeWithDraft}
>
</PlatformActionButton>
@@ -216,6 +368,37 @@ export function ResourceCanvasAssetGenerationPanelView({
</PlatformActionButton>
</div>
</form>
</>
);
if (variant === 'floating') {
return (
<section
/*
与模态共用同一套面板 chrome`game-approval-dialog` 提供边框/圆角/底色/内边距与
`> header` 排布,`game-resource-generation-dialog` 提供表单宽度口径。少任何一个,
浮层就会退化成没有背景边框、标题挤在一起的一块裸容器(真实浏览器复现过)。
*/
className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel"
role="dialog"
aria-label={action.label}
data-resource-canvas-generation-floating-panel=""
style={style ?? undefined}
onPointerDown={(event) => event.stopPropagation()}
>
{panelBody}
</section>
);
}
return (
<ThemedModal
open
ariaLabel={action.label}
onClose={closeWithDraft}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
{panelBody}
</ThemedModal>
);
}
@@ -1,5 +1,5 @@
import { Sparkles, X } from 'lucide-react';
import { type FormEvent, useRef, useState } from 'react';
import { type CSSProperties, type FormEvent, useRef, useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
@@ -36,8 +36,45 @@ export type ResourceCanvasGenerationPanelViewProps = {
*/
kinds?: readonly ResourceCanvasGenerationKind[];
initialKind?: ResourceCanvasGenerationKind;
/**
* 呈现形态。
*
* 面板底部栏目的音频入口(音效 / 背景音乐)与「生成素材」入口一样:工具点击先在当前栏目
* 建占位卡,浮层挂在占位卡下沿。占位与浮层的归属由宿主按 `draftId` 维护,面板只负责这一份
* 草稿与失败重试。
*/
variant?: 'modal' | 'floating';
style?: CSSProperties | null;
/**
* 初始草稿(音频 / 背景音乐入口用)。
*
* 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单,
* 用户不必重打一遍提示词。
*/
initialDraft?: {
kind: ResourceCanvasGenerationKind;
prompt: string;
assetName: string;
} | null;
/**
* 已绑定的提交身份。
*
* 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是
* 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。
*/
request?: ResourceEditRequestIdentity | null;
onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise<void>;
onClose: () => void;
/**
* 收起浮层。
*
* 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。
* 提交成功后的关闭不带草稿(这次输入已经被任务接走)。
*/
onClose: (draft?: {
kind: ResourceCanvasGenerationKind;
prompt: string;
assetName: string;
}) => void;
};
const RESOURCE_GENERATION_ALL_KIND_ITEMS =
@@ -66,6 +103,10 @@ function resourceGenerationErrorMessage(error: unknown) {
export function ResourceCanvasGenerationPanelView({
kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind),
initialKind,
variant = 'modal',
style,
initialDraft,
request: boundRequest,
onSubmit,
onClose,
}: ResourceCanvasGenerationPanelViewProps) {
@@ -83,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({
const option = resourceCanvasGenerationOption(kind);
const panelTitle =
allowedOptions.length === 1 ? option.generationLabel : '生成素材';
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(option.assetName);
const [prompt, setPrompt] = useState(initialDraft?.prompt ?? '');
const [assetName, setAssetName] = useState(
initialDraft?.assetName ?? option.assetName,
);
const [attempted, setAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸
// Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定
// 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。
const requestRef = useRef<ResourceEditRequestIdentity | null>(null);
const requestRef = useRef<ResourceEditRequestIdentity | null>(
boundRequest ?? null,
);
const inputLocked = attempted || submitting;
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
const closeWithDraft = () =>
onClose({ kind, prompt, assetName: assetName.trim() || option.assetName });
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -125,13 +173,8 @@ export function ResourceCanvasGenerationPanelView({
}
}
return (
<ThemedModal
open
ariaLabel={panelTitle}
onClose={onClose}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
const panelBody = (
<>
<header>
<div>
<h2>{panelTitle}</h2>
@@ -139,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
onClick={onClose}
onClick={closeWithDraft}
>
<X size={16} aria-hidden="true" />
</button>
@@ -200,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
onClick={onClose}
onClick={closeWithDraft}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
@@ -222,6 +265,33 @@ export function ResourceCanvasGenerationPanelView({
)}
</div>
</form>
</>
);
if (variant === 'floating') {
return (
<section
// 与模态共用面板 chrome(边框/圆角/底色/内边距 + `> header` 排布),浮层不另造外观。
className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel"
role="dialog"
aria-label={panelTitle}
data-resource-canvas-generation-floating-panel=""
style={style ?? undefined}
onPointerDown={(event) => event.stopPropagation()}
>
{panelBody}
</section>
);
}
return (
<ThemedModal
open
ariaLabel={panelTitle}
onClose={closeWithDraft}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
{panelBody}
</ThemedModal>
);
}

Some files were not shown because too many files have changed in this diff Show More