Compare commits

..

32 Commits

Author SHA1 Message Date
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
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
78 changed files with 12132 additions and 984 deletions
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.24",
"version": "2026-08-26.23",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -63,7 +63,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
"sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534"
},
{
"name": "agc-web-game-development",
@@ -19,12 +19,6 @@ 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.
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
generate the subject on a solid-colour background and automatically matte it
away afterwards, producing transparent-background results; write the prompt
for the subject only, never for a scene. `kind="image"` keeps the rendered
frame without extra processing.
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
required and has no default, so decide it explicitly:
@@ -550,7 +550,6 @@ 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, "screenColor", &mut out);
copy_string(object, "aspectRatio", &mut out);
copy_string(object, "imageSize", &mut out);
copy_string(object, "assetName", &mut out);
@@ -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,7 +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,
screen_color: None,
reference_asset_ids: Vec::new(),
target_category: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -9803,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");
@@ -2230,42 +2230,6 @@ fn validate_generate_image_slice_declaration(
Ok(())
}
/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
/// 避免服务端静默忽略造成“已生效”的误解。
fn normalize_generate_image_screen_color(
arguments: &Value,
kind: &str,
) -> Result<Option<String>, String> {
let Some(value) = arguments.get("screenColor") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
if !matches!(kind, "character" | "art-spritesheet") {
return Err(format!(
"工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
));
}
let raw = value
.as_str()
.ok_or_else(|| "工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string())?
.trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("auto") {
return Ok(None);
}
let normalized = raw.to_ascii_uppercase();
let valid = normalized.len() == 7
&& normalized.starts_with('#')
&& normalized[1..].chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return Err("工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string());
}
Ok(Some(normalized))
}
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(
@@ -2280,8 +2244,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
],
)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
@@ -2361,25 +2323,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
{
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
}
let slice_count = arguments
.get("sliceCount")
.filter(|value| !value.is_null())
.map(|value| {
value
.as_u64()
.filter(|count| (1..=256).contains(count))
.map(|count| count as usize)
.ok_or_else(|| "工具参数 sliceCount 必须是 1 到 256 的整数".to_string())
})
.transpose()?;
validate_generate_image_slice_declaration(
kind.as_str(),
slice_mode.as_deref(),
grid_x,
grid_y,
slice_count,
None,
)?;
let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
@@ -2387,11 +2337,12 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_kind: kind.clone(),
asset_label: asset_name.clone(),
replace_existing: false,
slice_count,
slice_count: None,
slice_mode,
grid_x,
grid_y,
screen_color,
reference_asset_ids: Vec::new(),
target_category: None,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2918,61 +2869,6 @@ mod tests {
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
}
#[test]
fn generate_image_screen_color_is_normalized_and_kind_gated() {
// 省略与显式 null 等价,且不触发 kind 门禁。
assert_eq!(
normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
None
);
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
.expect("null"),
None
);
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
for raw in ["auto", "AUTO", " auto ", ""] {
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
.expect("auto variants"),
None,
"{raw}"
);
}
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
.expect("lowercase hex"),
Some("#CFEFFF".to_string())
);
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": " #A0BBA0 "}),
"art-spritesheet"
)
.expect("padded hex"),
Some("#A0BBA0".to_string())
);
// 非 auto/非 hex、非字符串一律拒绝。
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
assert!(
normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
.is_err(),
"{bad}"
);
}
// 其它 kind 携带该字段直接拒绝,即使取值合法。
let gated =
normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
.expect_err("screenColor must stay scoped to character/art-spritesheet");
assert!(gated.contains("kind=character"), "{gated}");
assert!(normalize_generate_image_screen_color(
&json!({"screenColor": "auto"}),
"ui-prototype"
)
.is_err());
}
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
@@ -233,7 +233,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}),
json!({
"name": "agc_generate_image",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用。",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env",
"inputSchema": {
"type": "object",
"properties": {
@@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
"default": "image",
"description": "image=普通新图(不做额外处理)character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体)spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
"description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图"
},
"aspectRatio": {
"type": "string",
@@ -286,16 +286,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"minimum": 1,
"maximum": 32,
"description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 256,
"description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
},
"screenColor": {
"type": "string",
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
}
},
"required": ["prompt"],
@@ -1141,8 +1131,6 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
],
) {
return mcp_tool_result(error, Vec::new(), true);
@@ -1171,7 +1159,6 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS),
("outputPath", 512),
("sliceMode", 32),
("screenColor", 16),
] {
if arguments.get(field).is_some() {
if let Err(error) = bounded_tool_string(arguments, field, max_chars) {
@@ -2404,35 +2391,11 @@ mod tests {
assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"]));
assert!(image_tool["description"]
.as_str()
.is_some_and(|description| description.contains("仅在用户明确要求生成新图时调用")));
assert!(
image_tool["inputSchema"]["properties"]["kind"]["description"]
.as_str()
.is_some_and(|description| description.contains("自动抠图")
&& description.contains("prompt 只描述角色主体")
&& description.contains("不做额外处理")),
"kind description must carry the auto-matting semantics"
);
.is_some_and(|description| description.contains("不是本工具的限制")));
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"],
json!(1)
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"],
json!(256)
);
assert!(
image_tool["inputSchema"]["properties"]["screenColor"]["description"]
.as_str()
.is_some_and(|description| description.contains("抠图纯色背景")
&& description.contains("kind=character")
&& description.contains("不要附带色名")),
"screenColor description must carry the matting-background semantics"
);
assert!(
image_tool["inputSchema"]["properties"]["sliceMode"]
.get("default")
@@ -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,6 +416,10 @@ 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_retry_policy_at,
game_creator_agent_runtime_provider_transient_max_retries_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 retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
let identity = game_creator_agent_runtime_provider_retry_identity_for_mode(
provider_snapshot,
llm,
@@ -1365,9 +1365,17 @@ where
)?;
return Err("Provider 瞬态错误编码损坏".to_string());
};
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = effective_max_retries;
if attempt >= error_max_retries {
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
{
crate::provider_retry::remove_at(
root,
&provider_snapshot.agent_id,
@@ -1509,14 +1517,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 retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at(
let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at(
root,
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
llm.max_retries,
)?;
let max_retries = retry_policy.max_retries;
let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400;
let retry_autonomous_upstream_400 =
max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR;
for attempt in 0..=max_retries {
let request_slot = if attempt == 0 {
provider_snapshot.request_slot.clone()
@@ -1577,8 +1585,11 @@ 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());
};
// 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。
let error_max_retries = max_retries;
let error_max_retries = if error_kind == "upstream-400" {
max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT)
} else {
max_retries
};
if attempt >= error_max_retries {
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
public_error,
@@ -395,22 +395,12 @@ pub(crate) fn agent_runtime_run_profile_identity_at(
Ok((profile, String::new()))
}
/// 当前持久 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(
pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at(
root: &Path,
agent_id: &str,
run_id: &str,
configured_max_retries: u32,
) -> Result<AgentRuntimeProviderTransientRetryPolicy, String> {
) -> Result<u32, 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)?
@@ -426,8 +416,10 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at(
stored_profile,
stored_binding_fingerprint,
)?;
Ok(AgentRuntimeProviderTransientRetryPolicy {
max_retries: configured_max_retries,
retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
})
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))
}
@@ -577,7 +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,
screen_color: None,
reference_asset_ids: Vec::new(),
target_category: None,
};
if let Some(pending) = pending_action {
match recover_persisted_visual_generation_options(
@@ -629,7 +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,
screen_color: requested_options.screen_color,
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,7 +4944,8 @@ pub(crate) fn prepare_local_project_asset_generation(
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
screen_color: None,
reference_asset_ids,
target_category,
},
})
}
@@ -4936,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,
@@ -4945,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")?;
@@ -4963,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]
@@ -5003,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"));
@@ -5030,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!(
@@ -5042,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",
@@ -5058,7 +5171,9 @@ mod local_project_asset_generation_tests {
Some("4:3"),
None,
None,
None
None,
&[],
None,
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
@@ -5071,7 +5186,9 @@ mod local_project_asset_generation_tests {
None,
Some("4K"),
None,
None
None,
&[],
None,
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
@@ -5084,7 +5201,9 @@ mod local_project_asset_generation_tests {
None,
None,
Some("\u{7}名字"),
None
None,
&[],
None,
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
@@ -5097,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
@@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_
}
#[test]
fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() {
fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -671,16 +671,16 @@ fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile()
)
.expect("project init");
// 历史 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);
assert_eq!(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
"design-director",
"legacy-standard-run",
99,
)
.expect("legacy standard retry policy"),
3
);
let standard = bind_game_creator_agent_runtime_run_profile_at(
&root,
"design-director",
@@ -691,25 +691,25 @@ fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile()
)
.expect("bind standard profile");
assert_eq!(
game_creator_agent_runtime_provider_transient_retry_policy_at(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&standard.agent_id,
&standard.run_id,
0,
)
.expect("standard zero retry policy")
.max_retries,
.expect("standard zero retry policy"),
0
);
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);
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 parent = bind_game_creator_agent_runtime_run_profile_at(
&root,
@@ -720,16 +720,17 @@ fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile()
None,
)
.expect("bind autonomous parent profile");
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);
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
);
}
let child_link = AgentRuntimeTaskLink {
@@ -757,25 +758,14 @@ fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile()
append_game_creator_agent_runtime_task(&root, &child_state)
.expect("append autonomous child task projection");
assert_eq!(
game_creator_agent_runtime_provider_transient_retry_policy_at(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&child.agent_id,
&child.run_id,
0,
)
.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
.expect("autonomous child retry policy"),
12
);
fs::remove_file(game_creator_agent_runtime_run_profile_binding_path(
@@ -785,7 +775,7 @@ fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile()
))
.expect("remove autonomous child binding");
assert!(
game_creator_agent_runtime_provider_transient_retry_policy_at(
game_creator_agent_runtime_provider_transient_max_retries_at(
&root,
&child.agent_id,
&child.run_id,
@@ -4568,7 +4558,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() {
}
#[tokio::test]
async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() {
async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试")
.expect("project init");
@@ -4641,7 +4631,7 @@ async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget
"model": "supervisor-autonomous-upstream-400-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 2,
"maxRetries": 0,
"retryBackoffMs": 1
}}
}}
@@ -4686,7 +4676,10 @@ async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget
.expect("initial autonomous upstream 400 request");
assert_eq!(waiting.error_kind, "upstream-400");
assert_eq!(waiting.next_attempt, 1);
assert_eq!(waiting.max_retries, 2);
assert_eq!(
waiting.max_retries,
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity)
.expect("force autonomous upstream 400 retry due");
@@ -4732,7 +4725,10 @@ async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget
.collect::<Vec<_>>();
assert_eq!(retry_audits.len(), 1);
assert_eq!(retry_audits[0]["errorKind"], "upstream-400");
assert_eq!(retry_audits[0]["maxRetries"], 2);
assert_eq!(
retry_audits[0]["maxRetries"],
AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT
);
let lifecycle = records
.iter()
.filter(|record| {
@@ -6927,6 +6923,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>
);
}
@@ -0,0 +1,96 @@
import './resourceCanvasGenerationPanel.css';
import { Sparkles, X } from 'lucide-react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import {
RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS,
type ResourceCanvasGenerationPlaceholder,
} from './resourceCanvasGenerationPlaceholderModel';
export type ResourceCanvasGenerationPlaceholderCardViewProps = {
placeholder: ResourceCanvasGenerationPlaceholder;
/** 生成浮层是否正挂在这张占位下面:决定卡片的高亮与浮层开合。 */
active: boolean;
/** 拖动中:卡片只跟指针走,不参与任何过渡。 */
dragging: boolean;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
/** 点击卡片(不是删除按钮):打开 / 收起挂在它下面的生成浮层。 */
onTogglePanel: () => void;
/** 删除占位:只隐藏展示,不取消后台任务。 */
onRemove: () => void;
};
/**
* 画布上的生成占位卡。
*
* 它**不是**正式资源卡:没有 manifest 身份、没有预览、不参与资源投影与布局 sidecar,
* 只在宿主临时状态里活到「提交成功落卡」或「用户删掉它」为止。点击它开 / 收挂在它下沿的
* 独立生成浮层,拖动改的是宿主内存里的坐标(结果卡最终落在同一条最新位置)。
*
* 指针事件的接管只到本组件为止:`stopPropagation` 阻止画布的框选 / 平移当作空白处处理。
*/
export function ResourceCanvasGenerationPlaceholderCardView({
placeholder,
active,
dragging,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onTogglePanel,
onRemove,
}: ResourceCanvasGenerationPlaceholderCardViewProps) {
return (
<div
className={`game-resource-generation-placeholder${active ? ' is-active' : ''}${dragging ? ' is-dragging' : ''}`}
data-resource-canvas-generation-placeholder={placeholder.draftId}
data-resource-canvas-generation-placeholder-status={placeholder.status}
style={{
left: placeholder.x,
top: placeholder.y,
width: placeholder.width,
height: placeholder.height,
}}
role="button"
tabIndex={0}
aria-label={`${placeholder.assetName}${RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onLostPointerCapture={onPointerCancel}
onClick={(event) => {
event.stopPropagation();
onTogglePanel();
}}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onTogglePanel();
}}
>
<Sparkles size={18} aria-hidden="true" />
<strong>{placeholder.assetName}</strong>
<small>
{RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]}
</small>
<button
type="button"
aria-label={`删除占位 ${placeholder.assetName}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
>
<X size={12} aria-hidden="true" />
</button>
</div>
);
}

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