Compare commits

...

57 Commits

Author SHA1 Message Date
k88936 27e3d9bfd0 Merge remote-tracking branch 'origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m18s
Project CI / Backend tests (pull_request) Successful in 8m6s
Project CI / Native shell tests (pull_request) Successful in 9m16s
Project CI / Frontend tests (pull_request) Failing after 7m0s
Project CI / Repository checks (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m16s
2026-09-21 14:43:20 +08:00
kdletters 4d313a44eb 收口后台模板管理里程碑:回填验收证据并删除临时实施计划
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m32s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m26s
Project CI / Backend tests (push) Successful in 8m33s
Project CI / Repository checks (push) Successful in 4m50s
Project CI / Native shell tests (push) Successful in 9m20s
Project CI / Frontend tests (push) Successful in 5m57s
Project CI / AI game creator shell web tests (push) Successful in 3m19s
- 里程碑 Status 从 approved 更新为 implemented-awaiting-runtime-acceptance,并注明按验收口径未对真实 OSS 写入
- 新增验收证据:合同/权限/领域/存储/CLI 五层用例结果,本地 API 健康检查与未授权读取写入失败关闭,授权读取 9 个模板快照,受控浏览器桌面与 390×844 移动端 smoke
- 新增复审记录:写路径顺序与 409 语义、后台 DTO 结构上无法改动 ZIP/版本、封面公共域名与单 bucket 合同一致、迟到响应判据的覆盖方式
- 按实施计划第 4 步删除临时实施计划文件,里程碑文档保留为唯一验收依据
- 验证:check:doc-index(192 份)、check:encoding、git diff --check
2026-09-21 14:21:25 +08:00
kdletters 01ed9fadc6 修复手工 release 发布未透传 AGC 渠道
Project CI / AI game creator shell Rust crates (push) Successful in 2m45s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 7m54s
Project CI / Backend tests (push) Successful in 11m10s
Project CI / Repository checks (push) Successful in 6m17s
Project CI / Frontend tests (push) Successful in 8m0s
Project CI / Native shell tests (push) Successful in 11m32s
Project CI / AI game creator shell web tests (push) Has been cancelled
补充手工发布目标到 AGC 更新渠道的踩坑记录

补充 release 客户端包分区与补发流程说明
2026-09-21 14:04:02 +08:00
kdletters 02a7abeb3f 新增后台 AGC 模板管理与模板库内容寻址落库
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、tab 权限、API 客户端与 DTO 同步接入
- api-server 新增 /admin/api/agc-templates 读取与更新路由,复用现有后台鉴权与 tab 权限校验,权限不足按既有失败关闭口径拒绝
- module-assets 收敛模板库纯规则(active/inactive 投影、索引 schema 与重复项校验、ZIP 大小与摘要核对),platform-oss 承担对象存储读写、不可变对象复用与发布互斥锁
- shared-contracts 补后台模板 DTO;spacetime-module 账号存储适配保持既有 schema 不变
- AGC 壳内置模板索引与建项读取改为内容寻址对象,补模板安装、越界归档、索引不匹配等用例
- 模板库发布脚本支持定向发布与上架/下架合并、内容地址复用与锁语义,CLI 守卫用例同步;jenkins 两条客户端打包管线统一触发邮件通知 Job 并传递首装包链接
- 内置 5 个 Cocos 官方模板载荷与索引 fixture(内容地址对象,共 90 个文件)
- 补「后台模板管理」里程碑与实施计划,更新模板库技术方案、开发运维文档与共享记忆
- 验证:admin-web 39 项用例与 typecheck、发布脚本 25 项用例、api-server 与 AGC 壳编译、模板库定向 Rust 用例(AGC 壳 18 项、module-assets/platform-oss 16 项、后台权限 1 项)、check:encoding / check:doc-index / check:production-ops / rustfmt 全部通过
2026-09-21 13:56:20 +08:00
suzmii 6845cdcbc5 修复 Mac 包校验脚本对合法随包 npm 的误判
Project CI / AI game creator shell Rust smoke (push) Successful in 2m14s
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- check-macos-bundle 的「包里不得出现 node_modules」门禁只放行了 game-runtime/node/node_modules/npm,但 recursive readdir 会把目录项 game-runtime/node/node_modules 也列出来,于是合法包必然断言失败
- 这条门禁是 #439 引入的,之后每次构建都死在更早的阶段(staging/测试),直到 2026-09-21 arm64 单架构跑通才第一次走到它:12:58、13:24、13:43 三轮都挂在这里
- 修复:放行 npm 目录自身、其子树与它的上级 node_modules 容器目录;已用真实 arm64 产物跑通完整 smoke(资源、架构、摘要、权限、Codex 正式查找、app-server 握手、缺组件拒绝全部通过)
2026-09-21 13:50:32 +08:00
suzmii 4044ffd89b 修复 Mac 发布 Job 手工触发时 Checkout 因未定义参数失败
- 手工「立即构建」不会注入可选参数(COMMIT_HASH / SKIP_IF_SUPERSEDED 未定义而非空值),set -u 下 `[ -n "$COMMIT_HASH" ]` 直接以 unbound variable 退出,Job 2026-09-21 12:54 那一轮就是这样在 Checkout 失败的
- 两段 Checkout 脚本开头统一兜底:SOURCE_BRANCH 默认 master、COMMIT_HASH 默认空、SKIP_IF_SUPERSEDED 默认 false,调度器显式传参时行为不变(仍按 pinned commit 构建并判让位)
2026-09-21 13:50:32 +08:00
suzmii 8d0c021126 修正 Mac 发布文档口径:自动触发与描述原文
- 运维文档原写「不设置 trigger;不接入现有每小时版本调度」,与现状矛盾:调度 Job 会先发号再把 SOURCE_BRANCH/COMMIT_HASH/AGC_UPDATE_CHANNEL/AGC_RELEASE_VERSION/SKIP_IF_SUPERSEDED 透传给 Mac Job,正常发布不需要手工点构建
- 把 Jenkins Job 描述与节点描述的当前原文(arm64 单架构 + 由调度器自动触发 + dry-run 默认关)写进文档,并要求每次改口径同步 Jenkins 侧描述,避免描述停留在 universal/手动触发的旧口径
2026-09-21 13:50:32 +08:00
k88936 c6f90c5b22 Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
Project CI / Backend tests (pull_request) Successful in 7m13s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 9m35s
Project CI / Repository checks (pull_request) Successful in 5m57s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m48s
2026-09-21 13:39:16 +08:00
kdletters 1cb91f9444 修复画布绑定查询与生成失败归因
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 画布绑定前置的项目列表查询改为 view=summary,站内与外部路由统一支持摘要视图(缺省 full 兼容,未知取值 400)
- 项目摘要投影收敛到 editor_project,外部 API 与 MCP 复用同一份实现,摘要不做内联媒体修复也不回传画布与全量资源
- 外部编辑器请求失败文案补 kind 语义与底层因链,区分请求超时、正文截断与非法 JSON,不拼接 URL
- 远端资源编辑终态文案带出稳定失败码并指向移出恢复队列这一唯一出口,上游原文仍不写入账本
- 补齐站内摘要视图与画布测试夹具的请求断言用例
- 更新共享决策记录与踩坑记录(图集切片上限条目归上游 #442,本提交不重复)
2026-09-21 13:33:23 +08:00
kdletters 2e31a485d8 修复 Stdb publish 事务确认超时被误判为失败
Project CI / AI game creator shell Rust crates (push) Successful in 3m0s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m0s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m44s
Project CI / Backend tests (push) Successful in 8m52s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- production-stdb-publish 新增 run_spacetime_publish:仅在出现 timeout waiting for transaction confirmation 时重试一次同版本 publish(SpacetimeDB 对已生效的同版本为幂等),重试成功即视为模块已在位,不再把生产留在维护态
- 其它失败原因保持原有失败关闭语义;生产运维门禁断言补上该重试口径
2026-09-21 13:27:37 +08:00
suzmii 4ad131d74d Merge pull request 'AGC 画布:锚点让位标题栏、生成浮层贴边、拖动误触点击、读秒 100ms 与图集参考口径' (#448) from fix/agc-canvas-bugfix into master
Project CI / AI game creator shell Rust smoke (push) Successful in 3m58s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m26s
Project CI / AI game creator shell Rust crates (push) Successful in 1m40s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 8m43s
Project CI / Frontend tests (push) Failing after 5m41s
Project CI / Backend tests (push) Successful in 7m29s
Project CI / Repository checks (push) Successful in 4m50s
Project CI / Native shell tests (push) Successful in 9m32s
Project CI / AI game creator shell web tests (push) Has been cancelled
Reviewed-on: #448
2026-09-21 13:04:11 +08:00
suzmii 562e1ba586 「生成任务」侧栏读秒改为 100ms 粒度:与对话侧共用同一份时钟
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 4m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m15s
Project CI / Repository checks (pull_request) Failing after 20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m16s
Project CI / Frontend tests (pull_request) Successful in 7m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m1s
Project CI / Native shell tests (pull_request) Successful in 11m1s
- ResourceCanvasAssetGenerationTasksPanelView:删掉面板自带的 1000ms interval 与 nowMillis 状态;两条任务行的「已耗时」下沉为叶子组件(AssetGenerationElapsedLabel / ResourceEditElapsedLabel),各自订阅共享 hook useLiveNow(LIVE_TIMER_TICK_MS = 100)。
- 为什么不是面板级时钟:面板上通常同时挂着多条任务,100ms 的 tick 落在面板上等于每 0.1 秒重建整个侧栏;useLiveNow 的注释明确要求调用方是叶子节点。终态行不订阅,已耗时定格在账本给的结束时间上。
- 行为:不足一分钟的耗时本来就显示到 0.1 秒(formatElapsedDuration),现在每 100ms 真的更新一次,不再是「带小数却 1 秒跳一格」。
- 用例:新增「读秒按 100ms 刷新(0.0 → 0.1 → 0.5 秒)」「终态行不再走表,定格在结束时间」「秒表只订在运行中的那一行上(setInterval 只被调用一次)」;把 LIVE_TIMER_TICK_MS 临时改回 1000 时第一条用例即红,粒度确实被钉住。
2026-09-21 12:48:21 +08:00
suzmii b4ce6877c8 修掉「拖动占位卡仍触发点击」的真因:点击抑制的计时起点必须在松手那一刻
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。
- 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。
- 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。
- 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。
- 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
2026-09-21 12:34:47 +08:00
suzmii 7b57cba14d 补齐「图集(生成图标素材)为什么没有 @」的当面说明与依据
- 生成浮层:不给 @ 选择器的入口新增一段说明行(`data-resource-canvas-generation-reference-free`),讲清「该入口走平台图集通道,参考只有一张权威规范图(图标规范),不支持另挑参考图」——验收现场那条「生成图标素材没法 @」读起来像漏了按钮,原因其实在通道上。
- 样式:说明行允许折行、沿用次要文字色;与既有的「参考图 n/5」计数行区分开。
- 用例:图集入口断言说明行在场;逐条入口断言「说明行」与「@ 选择器」严格互补;原「不呈现参考选择」的判据从按词匹配改成按计数元素/计数值匹配(说明行本身会提到「参考图」三个字)。
- 文档:入口矩阵补「图集为什么没有 @」一条,写明图集走 `icon-spritesheets` 通道、提交体只有 referenceId + iconDescriptions(无 referenceImageSrcs)、结果绑定硬校验参考恰好一张,以及「平台 OpenAPI 该请求 schema 有 referenceImageSrcs 字段,放开属于产品 + 平台口径变更」。
2026-09-21 12:23:32 +08:00
suzmii beebcca4de 修复 AGC 画布四项验收问题:锚点让位标题栏、浮层不再撞画布边缘、拖动不再触发点击、图标规范参考口径对齐
- 生成任务锚点新增「资源总览」档:资源总览页仍贴画布顶边内缩 0.85rem,资源栏目画布页让开顶部钉住的整宽栏目标题栏(42px + 0.5rem),两档切换走 margin-top 过渡(减动效下关掉)。
- 生成浮层的顶边与可用高度改为按占位卡的真实屏幕位置算(卡下沿到安全底边),不再假设卡片贴在安全带上沿——占位落到下半屏时浮层底边不再越过画布下沿被切掉。
- 生成浮层新增水平收口:按「浮层左右各留 12px」把锚点收进画布可视范围,卡片靠边时浮层不再有一半落到画布之外;宽度口径与面板 CSS 同源(min(560px, 100% - 24px))。
- 新增画布卡片手势的共享判据 resourceCanvasGestureExceededDragThreshold(阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD):资源卡与生成占位卡共用同一份「拖动还是点击」判断。
- 生成占位卡修掉拖动收尾误触点击:越过阈值才算拖动、登记一次性点击抑制(consumeDragClick)由宿主消费,「拖动中」样式也从越过阈值那一刻起才亮。
- 生成占位卡的删除按钮起手手势不再被当成「点卡片」:按起手点判定,松手落回卡片时不触发开合生成浮层。
- 图标规范面板的参考选择注释按现行判据改写(只有图集 icon-spritesheet 不接受用户参考),入口矩阵文档同步补「参考图(@ 引用)」口径并修掉「本地 IPC 没有参考图入参」的旧说法。
- 补齐回归用例:手势模型判据、占位卡拖动/点击与阈值、宿主级「拖完不弹浮层且普通点击仍开合」、浮层顶边与水平收口、锚点分档与跨文件标题栏高度守卫、逐条生成入口的 @ 与判据一致、图标规范面板的 @ 与提交带引用。
- 共享记忆 pitfalls.md 记录两条排障口径:卡类手势必须只有一份阈值与点击抑制判据;画布浮层几何只能按真实屏幕位置算、分页锚点必须让开钉死的标题栏。
2026-09-21 11:58:13 +08:00
k88936 3b137ca369 合并 master 并保留 GPT Image 2.5 与外置提示词行为
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m1s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
Project CI / Native shell tests (pull_request) Successful in 9m25s
Project CI / Backend tests (pull_request) Successful in 10m37s
Project CI / Repository checks (pull_request) Successful in 7m52s
Project CI / Frontend tests (pull_request) Failing after 10m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m25s
保留编辑器图片工具使用 gpt-image-2.5 业务模型名

保留工具参数说明从 prompts 外置文件读取

同步外置模型与尺寸文案并完成冲突验证
2026-09-21 10:50:32 +08:00
k88936 3da0936c1f Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m34s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m37s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m23s
Project CI / Frontend tests (pull_request) Failing after 14m1s
Project CI / Repository checks (pull_request) Successful in 12m35s
Project CI / Native shell tests (pull_request) Successful in 17m2s
Project CI / Backend tests (pull_request) Successful in 17m18s
Project CI / AI game creator shell web tests (pull_request) Failing after 14m48s
2026-09-19 15:39:41 +08:00
k88936 c8311a47b0 Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
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
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
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/state.rs
#	src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
2026-09-19 15:37:29 +08:00
k88936 1c1deff732 修复编辑器 LLM mock 用例无法构造 Tiantoken client 的问题
- AppConfig 新增 tiantoken_base_url / tiantoken_api_key 字段,from_env 只读 TIANTOKEN_* 环境变量,不回退 VectorEngine 凭据
- AppState 改用配置字段构造编辑器 LLM 与 Tiantoken 图片 client,删除现读环境变量的 config::tiantoken_* 访问器
- editor_background_music_prompt_assist / editor_sound_effect_prompt_assist / sound_effect_translation 三处 mock LLM 夹具改用 tiantoken_* 字段,恢复 28 条失败用例
- config 用例改为断言 from_env 的 Tiantoken 凭据来源,state 用例改名为从 Tiantoken 设置构造编辑器 LLM client
- 同步更新后端架构文档与 decision-log 记录
2026-09-19 15:33:14 +08:00
k88936 0459109897 修复 SpacetimeDB 定价 record 缺少历史模型回填的问题
- 将 backfill_legacy_gpt_image_2_5_pricing 提升为 pub(crate) 并补充文档说明其覆盖磁盘 override 与 SpacetimeDB record 两条加载路径
- editor_generation_pricing_from_record 在 validate 前先对旧 record 做受控 backfill,避免升级前只写单个 gpt-image-2 key 时被必填校验拒绝
- 新增用例覆盖旧 record 自动补齐两个 GPT Image 2.5 key,以及显式配置不被回填覆盖
- 更新兼容回填的 TODO 注释,说明移除条件还需包含 SpacetimeDB record
2026-09-19 15:18:40 +08:00
k88936 6318baf7c6 修正缺省图片模型校验用例的 fixture 模型 key
- 将 editor_generation_pricing_rejects_missing_required_image_size 的 fixture 从已退役的 gpt-image-2 换成 gpt-image-2.5-flare-c / gpt-image-2.5-sunburst-c
- 同步断言文案为 models.gpt-image-2.5-flare-c 缺少 2K 的泥点配置,使缺档校验仍覆盖真实必填模型
2026-09-19 15:17:45 +08:00
k88936 28e6e67e98 补齐后台定价保存用例的 GPT Image 2.5 档位
后台保存 payload 用 gpt-image-2.5-flare-c 与 gpt-image-2.5-sunburst-c 替换历史单 key,保存重新返回 200

公开定价断言改为读取业务模型名 gpt-image-2.5 的生成档价格,并新增 admin 定价读回具体 key 的断言
2026-09-19 14:23:18 +08:00
k88936 4415723f7f 同步公开定价路由用例到 GPT Image 2.5 公开投影
公开定价断言改为读取业务模型名 gpt-image-2.5 的生成档价格

补充断言公开投影不暴露 gpt-image-2 与两个 provider 具体 key
2026-09-19 14:21:33 +08:00
k88936 fa84a364d6 修正宣发素材生成锁定的模型断言
源码文本断言从 Some(GPT_IMAGE_2_MODEL) 更新为 Some(GPT_IMAGE_2_5_BUSINESS_NAME),与当前锁定实现一致

用例改名为 publication_material_generation_is_locked_to_gpt_image_2_5
2026-09-19 14:20:29 +08:00
k88936 bd0a06e3d3 兼容图片工具的历史 gpt-image-2 模型别名
api-server 的图片类 Agent 工具在反序列化前把 gpt-image-2 / gpt-image-2-c 归一为 gpt-image-2.5,旧持久化工具调用不再被校验直接拒绝

EditImageTool 计价改用 gpt-image-2.5 业务模型常量,并新增别名归一单测、更新受影响的校验与计价断言
2026-09-19 13:55:30 +08:00
k88936 939c35a9b9 统一缺省图片模型的归一化结果
normalize_image_model 的空白模型回退改为 GPT_IMAGE_2_5_GENERATION_MODEL,与 build_image_request_body 默认值一致

补充单测断言空白模型与默认生成模型产出同一请求体
2026-09-19 13:48:11 +08:00
k88936 42fdb57efe 校验图片请求模型与 provider 绑定一致
executor 三个请求入口改用 ensure_provider_matches_model,模型不属于当前 provider 时直接返回 InvalidRequest

新增单测覆盖 provider 不一致的快速失败,并把 GPT 模型相关集成测试的 settings 调整为 tiantoken
2026-09-19 13:46:47 +08:00
k88936 0c54cca81c 修正生成规范缺省模型的计价档
spec_model_mud_points 在模型为空时显式回退 GPT Image 2.5 生成档,避免落到 nanobanana 档

补中文注释说明与 normalize_editor_generation_options 的缺省模型保持一致
2026-09-19 13:43:12 +08:00
k88936 1a2e60bf05 修复图片编辑缺少参考图时的 panic
platform-image 新增 missing_reference_images_error 并在 create_image_edit_with_references_and_model 中复用

api-server 两个多参考图编辑入口在解析 provider 客户端前先校验参考图,返回 400 而不是 panic

恢复 vector_engine_multi_reference_edit_rejects_empty_references 期望的缺少参考图错误
2026-09-19 13:41:21 +08:00
k88936 5ce267ae85 按参考图选择角色主形象的 provider 模型
角色主形象带参考图时走后端编辑端点,模型改用 gpt-image-2.5-sunburst-c

无参考图生成继续使用 gpt-image-2.5-flare-c
2026-09-19 13:37:55 +08:00
k88936 8924bf335e 修正 UI 设计素材提取的编辑模型
UI 设计图 spritesheet 提取走后端编辑端点,模型由 gpt-image-2.5-flare-c 改为 gpt-image-2.5-sunburst-c
2026-09-19 13:36:28 +08:00
k88936 ae257a6ff8 按端点选择编辑器生成的 provider_model
generate_editor_image_for_owner 在参考图存在时改用 gpt-image-2.5-sunburst-c(编辑模型)

无参考图生成继续使用 gpt-image-2.5-flare-c,nanobanana 分支沿用自身模型
2026-09-19 13:36:03 +08:00
k88936 ff4f42d2f2 修正编辑器生成模型归一化注释
注释改为与实现一致:未显式传模型时归一为 gpt-image-2.5,而非继续走 gpt-image-2
2026-09-19 13:35:05 +08:00
k88936 2e2ed47bec 补齐图标 spritesheet 请求的 provider_model
editor_project_icon 的 EditorImageProviderRequest 增加 provider_model 字段

参考图存在时按编辑模型 gpt-image-2.5-sunburst-c 提交,nanobanana 与无参考图分支分别沿用自身模型
2026-09-19 13:29:11 +08:00
k88936 dedc58cce1 补齐 api-server 的 GPT_IMAGE_2_C_MODEL 重导出
openai_image_generation 重新导出 platform_image 的 GPT_IMAGE_2_C_MODEL

修复 editor_project 兼容历史 gpt-image-2-c 时引入的未解析导入与随之产生的模式绑定错误
2026-09-19 13:27:31 +08:00
k88936 c86ac04220 恢复 image_provider 单元测试模块
image_provider/mod.rs 重新声明 #[cfg(test)] mod tests,tests.rs 去掉失效的模块包裹并补齐 helper 导入

提取器 extract_image_urls / extract_b64_images 放宽到 pub(crate),parse_reference_image_data_url 调用补 provider 参数

补回 5 个此前被静默跳过的单元测试
2026-09-19 13:26:35 +08:00
k88936 0817ea8158 补齐审计模型白名单的 GPT Image 2.5 与 nanobanana 模型
auditable_image_model 识别 gpt-image-2.5、gpt-image-2.5-flare-c、gpt-image-2.5-sunburst-c 与 nanobanana2

避免新模型请求的审计 image_model 与错误元数据丢失模型归因
2026-09-19 13:25:09 +08:00
k88936 e393a803f2 修正 executor 日志与重试错误的 provider 归属
生成 / 编辑路径的 tracing 字段与参考图数量校验错误改用 settings.provider

nanobanana 路径改用 VectorEngine provider

重试辅助函数 retry_image_send_after_delay / retry_image_upstream_status_after_delay 增加 provider 参数
2026-09-19 13:23:50 +08:00
k88936 89587c9e9e 修正 raw image edit 的 provider 归属
created_raw_image_edit 用 settings.provider 统一驱动审计、日志与错误构造

invalid_request / invalid_input / request_error 增加 provider 参数,不再写死 vector-engine
2026-09-19 13:22:20 +08:00
k88936 5cba2dbeb7 修正 libcurl 传输错误的 provider 归属
map_curl_error 接收实际 provider,审计、日志与 Request 错误不再写死 vector-engine

executor 生成 / 编辑路径传入 settings.provider,nanobanana 路径传入 VectorEngine
2026-09-19 13:20:44 +08:00
k88936 d3407e8910 修正预算与图片下载错误的 provider 归属
request_budget_exhausted_error 接收实际 provider,审计、日志与 Request 错误不再写死 vector-engine

download_remote_image / download_images_from_urls / resolve_reference_images / parse_reference_image_data_url 逐层透传 provider

executor 与 raw image edit 调用处传入各自 settings.provider,nanobanana 下载路径传入 VectorEngine
2026-09-19 13:19:08 +08:00
k88936 4888a0eb01 修正响应解析错误的 provider 归属
parse_json_payload 增加 ImageProvider 参数,ResponseParse 使用实际 provider

共享的 handle_image_response 解析失败时不再把 Tiantoken 响应记成 vector-engine
2026-09-19 13:16:25 +08:00
k88936 90b77d6bd0 修正共享响应处理的 provider 归属
handle_image_response 与 build_failure_audit 接收实际 provider,不再写死 vector-engine

executor 生成 / 编辑路径传入 settings.provider,nanobanana 路径传入 VectorEngine

budget / curl / raw edit 的审计先沿用原常量,待各自按实际 provider 收口
2026-09-19 13:16:03 +08:00
k88936 1c950df116 删除已失效的跨模型回退分支
移除 generation / edit 重试路径中恒为 false 的 should_fallback_to_gpt_image_2_c 判断与 record_image_model_fallback 调用

删除为计算该判断而额外调用 handle_image_response 的预解析,避免每次重试都白解析响应并下载图片

删除只断言该函数恒为 false 的单元测试,跨模型不切换已由集成测试覆盖
2026-09-19 13:14:23 +08:00
k88936 6c33459b90 为 ImageProviderSettings 增加脱敏 Debug 实现
手动实现 Debug 并在其中把 api_key 渲染为 <redacted>

settings() 公开返回该结构体,默认 derive 会让 {:?} 直接打印 provider 凭据
2026-09-19 13:09:58 +08:00
k88936 874d061cb9 统一 ImageProvider 模型路由使用的 nanobanana 常量
resolve_image_provider 改用 NANOBANANA_2_MODEL 常量匹配 VectorEngine 模型

避免常量更新后路由仍匹配写死的旧模型字符串
2026-09-19 13:09:28 +08:00
k88936 fa2b36654b 修正 ImageProvider 生成测试的错误命名
将 image_generation_uses_gpt_image_2_without_fallback_on_success 更名为 image_generation_uses_default_generation_model_on_success

测试实际覆盖 GPT Image 2.5 默认模型且跨模型回退已删除,旧名称会误导后续模型迁移
2026-09-19 13:09:10 +08:00
k88936 345c9c3a99 收敛 ImageProvider 集成测试的模型断言
集成测试改用 GPT_IMAGE_2_5_GENERATION_MODEL / GPT_IMAGE_2_5_EDIT_MODEL 常量拼接断言

避免常量改名后遗留过期的 wire model 字符串断言
2026-09-19 13:08:50 +08:00
k88936 99081455ee 移除 image_provider transport 冗余 path 属性
去掉 transport 模块上多余的 #[path] 声明,恢复默认模块解析

保持 client / curl 模块导出与人眼可见的目录结构一致
2026-09-19 13:07:21 +08:00
k88936 5b1b0ddb9a 补齐 GPT Image 2.5 前端回归测试
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m51s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m28s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m52s
Project CI / Frontend tests (pull_request) Failing after 14m17s
Project CI / Native shell tests (pull_request) Successful in 16m11s
更新模型标签、提交 payload 与固定模型断言

覆盖 gpt-image-2-c 历史恢复及参数回退告警

保留历史 fixture 原值并验证 nanobanana 默认不变
2026-09-18 23:31:47 +08:00
k88936 f4315ffd23 更新主站 GPT Image 2.5 生成边界
将 GPT 专用新任务与锁定模型切换为 gpt-image-2.5

保留 nanobanana 普通生成默认行为

将历史 gpt-image-2 与 gpt-image-2-c 归一并触发参数回退告警
2026-09-18 23:30:48 +08:00
k88936 beb3c99240 同步 GPT Image 2.5 契约与工具文档
更新 OpenAPI 与外部编辑器模型说明

补充历史模型兼容解析与参数回退告警约定

将现役 ImageGen 工具脚本切换到 GPT Image 2.5
2026-09-18 23:28:45 +08:00
k88936 1574f206a2 整理图片 provider 模块层次并拆出 raw edit
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m26s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m39s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m37s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m28s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m26s
Project CI / Frontend tests (pull_request) Failing after 5m49s
Project CI / Native shell tests (pull_request) Successful in 10m45s
将 raw image edit 提取到独立 raw_image_edit 模块

按 protocol、runtime、transport 目录收拢 image_provider 实现

同步后端架构与 raw edit 技术文档路径
2026-09-18 17:45:09 +08:00
k88936 1367e9c04e 兼容历史 GPT Image 2-c 提交模型
读取历史 gpt-image-2-c 时按当前 GPT Image 2.5 业务任务处理

保留历史字符串不改写并沿用新的生成与编辑定价路由
2026-09-18 16:50:10 +08:00
k88936 b294cbc3db 接入 GPT Image 2.5 双 provider 启动路由
启动时分别构造 VectorEngine 与 Tiantoken 图片 client

按生成、编辑和 nanobanana 模型选择具体 provider

迁移 api-server、编辑器 Agent 与 raw edit 调用方

拆分 GPT Image 2.5 生成与编辑定价并隐藏 provider 具体值
2026-09-18 16:28:45 +08:00
k88936 c34d24c2c8 重构图片 provider 中立执行层
将 platform-image 图片协议从 vector_engine 目录迁移到 image_provider

引入 ImageProviderClient 与 VectorEngine/Tiantoken provider 类型

按 concrete model 白名单拒绝未知模型并移除跨模型回退
2026-09-18 16:03:29 +08:00
k88936 0dbc34d13c 明确 GPT Image 2.5 provider 边界与路由计划
更新业务模型、具体模型与 provider client 领域术语

记录 Tiantoken/VectorEngine 双 client 启动约束

同步开发运维文档中的图片路由与环境变量要求
2026-09-18 16:01:32 +08:00
254 changed files with 25506 additions and 1995 deletions
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
+6 -6
View File
@@ -1,11 +1,11 @@
---
name: gpt-image-2-apimart
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
---
# gpt-image-2 VectorEngine
# GPT Image 2.5 project image workflow
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
## Workflow
@@ -40,7 +40,7 @@ Default body:
```json
{
"model": "gpt-image-2",
"model": "gpt-image-2.5",
"prompt": "<prompt>",
"n": 1,
"size": "1024x1024"
@@ -58,14 +58,14 @@ Content-Type: multipart/form-data
Multipart fields:
```text
model=gpt-image-2
model=gpt-image-2.5
prompt=<prompt>
n=1
size=1024x1024
image=@reference.png
```
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
@@ -9,8 +9,7 @@ const skillRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
const preferredImageModel = 'gpt-image-2.5';
const prompts = [
{
@@ -256,41 +255,8 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, entry) {
for (const model of [preferredImageModel, fallbackImageModel]) {
for (const model of [preferredImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(entry),
@@ -322,12 +288,7 @@ async function requestImagePayload(env, entry) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
throw error;
}
}
throw new Error(`VectorEngine returned no image for ${entry.id}`);
@@ -408,7 +369,7 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({
id: entry.id,
title: entry.title,
fallbackModel: fallbackImageModel,
fallbackModel: null,
body: {
model: preferredImageModel,
prompt: buildPrompt(entry),
@@ -18,8 +18,7 @@ const defaultOutDir = path.join(
'puzzle-creation-templates',
);
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
const preferredImageModel = 'gpt-image-2.5';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
@@ -226,41 +225,8 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, template) {
for (const model of [preferredImageModel, fallbackImageModel]) {
for (const model of [preferredImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(template),
@@ -294,12 +260,7 @@ async function requestImagePayload(env, template) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
throw error;
}
}
throw new Error(`VectorEngine returned no image for ${template.id}`);
@@ -384,7 +345,7 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
fallbackModel: fallbackImageModel,
fallbackModel: null,
body: {
model: preferredImageModel,
prompt: buildPrompt(template),
+29
View File
@@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
**图片模型历史值与使用端解析**:
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
**图片 provider 显式路由**:
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
**业务模型**:
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
**具体模型**:
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c`nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
**provider client**:
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API keyVectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
**历史模型值**:
已持久化的 `gpt-image-2``gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -3,12 +3,14 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -18,6 +20,51 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+20
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -47,6 +48,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -1176,3 +1178,21 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
+32
View File
@@ -1033,3 +1033,35 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
+7
View File
@@ -19,6 +19,7 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -294,6 +295,12 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
+1
View File
@@ -53,6 +53,7 @@ const routeIcons = {
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
@@ -147,3 +147,30 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
+2
View File
@@ -18,6 +18,7 @@ export type AdminRouteId =
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
+1
View File
@@ -1,3 +1,4 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
@@ -0,0 +1,498 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
getAdminAgcTemplates,
updateAdminAgcTemplate,
} from '../api/adminApiClient';
import type {
AdminAgcTemplateLibraryResponse,
AdminAgcTemplatePayload,
} from '../api/adminApiTypes';
import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
vi.mock('../api/adminApiClient', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/adminApiClient')>()),
getAdminAgcTemplates: vi.fn(),
updateAdminAgcTemplate: vi.fn(),
}));
const template: AdminAgcTemplatePayload = {
id: 'cocos-empty-2d',
title: '空白 2D',
summary: '二维项目',
tags: ['2D', '入门'],
runtime: 'cocos',
engine: 'Cocos Creator',
engineVersion: '3.8.8',
templateVersion: '1',
enabled: true,
coverUrl: 'https://example.test/cover.png',
zipSizeBytes: 2048,
};
const library: AdminAgcTemplateLibraryResponse = {
revision: 'rev-1',
writable: true,
templates: [
template,
{
...template,
id: 'godot-starter',
title: 'Godot 起步',
runtime: 'godot',
engine: 'Godot',
engineVersion: '4',
tags: ['3D'],
enabled: false,
},
],
};
beforeEach(() => {
vi.mocked(getAdminAgcTemplates)
.mockReset()
.mockImplementation(async () => structuredClone(library));
vi.mocked(updateAdminAgcTemplate)
.mockReset()
.mockImplementation(async (_token, id, update) => ({
...structuredClone(library),
revision: 'rev-2',
templates: library.templates.map((entry) =>
entry.id === id
? {
...entry,
title: update.title,
summary: update.summary,
tags: update.tags,
enabled: update.enabled,
}
: entry,
),
}));
vi.stubGlobal(
'URL',
Object.assign(class extends URL {}, {
createObjectURL: vi.fn(() => 'blob:template-cover'),
revokeObjectURL: vi.fn(),
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function openEditor() {
fireEvent.click(await screen.findByRole('button', { name: '编辑 空白 2D' }));
return screen.getByRole('dialog', { name: '编辑模板' });
}
async function confirmWrite() {
const confirmation = await screen.findByRole('dialog', { name: '确认操作' });
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, resolve, reject };
}
test('按名称、ID、标签、运行时和上下架状态筛选,展示版本与封面', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('空白 2D');
expect(screen.getByText('Cocos Creator 3.8.8')).not.toBeNull();
expect(screen.getAllByText('模板 1')).toHaveLength(2);
expect(screen.getAllByText('2.0 KiB')).toHaveLength(2);
expect(
screen.getByRole('img', { name: '空白 2D封面' }).getAttribute('src'),
).toBe(template.coverUrl);
for (const value of ['空白', 'cocos-empty-2d', '入门']) {
fireEvent.change(screen.getByLabelText('搜索模板'), { target: { value } });
expect(screen.getByText('空白 2D')).not.toBeNull();
expect(screen.queryByText('Godot 起步')).toBeNull();
}
fireEvent.change(screen.getByLabelText('搜索模板'), {
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('运行时'), {
target: { value: 'godot' },
});
expect(screen.queryByText('空白 2D')).toBeNull();
fireEvent.change(screen.getByLabelText('上架状态'), {
target: { value: 'enabled' },
});
expect(screen.getByText('没有符合筛选条件的模板')).not.toBeNull();
});
test('独立弹窗保存经写确认,只提交展示字段和冻结 revision', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
expect(editor.closest('.admin-agc-templates')).toBeNull();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: ' 新名称 ' },
});
fireEvent.change(within(editor).getByLabelText('简介'), {
target: { value: '新简介' },
});
fireEvent.change(within(editor).getByLabelText('标签'), {
target: { value: '2D, 新手,2D' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
expectedRevision: 'rev-1',
title: '新名称',
summary: '新简介',
tags: ['2D', '新手'],
enabled: true,
}),
);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑模板' })).toBeNull(),
);
expect(screen.getByText('新名称')).not.toBeNull();
expect(screen.getByText('已保存')).not.toBeNull();
});
test('上下架复用确认且只用服务端结果更新列表', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '下架 空白 2D' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
expectedRevision: 'rev-1',
title: template.title,
summary: template.summary,
tags: template.tags,
enabled: false,
}),
);
expect(
await screen.findByRole('button', { name: '上架 空白 2D' }),
).not.toBeNull();
});
test('只读模式禁用字段、保存与上下架', async () => {
vi.mocked(getAdminAgcTemplates).mockResolvedValue({
...library,
writable: false,
});
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
expect(
await screen.findByText('当前为只读模式,无法保存或上下架'),
).not.toBeNull();
expect(
(screen.getByRole('button', { name: '下架 空白 2D' }) as HTMLButtonElement)
.disabled,
).toBe(true);
const editor = await openEditor();
expect(
(within(editor).getByLabelText('名称') as HTMLInputElement).disabled,
).toBe(true);
expect(
(within(editor).getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('409 保留草稿,显式刷新后重新编辑才采用新 revision,不自动重试', async () => {
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
new AdminApiError({ message: '模板库已被修改', status: 409 }),
);
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
let editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '未保存草稿' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await screen.findByText('模板库已被修改');
expect((screen.getByLabelText('名称') as HTMLInputElement).value).toBe(
'未保存草稿',
);
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
vi.mocked(getAdminAgcTemplates).mockResolvedValueOnce({
...library,
revision: 'rev-3',
templates: [{ ...template, title: '最新名称' }],
});
fireEvent.click(screen.getByRole('button', { name: '刷新后重新编辑' }));
await screen.findByDisplayValue('最新名称');
editor = screen.getByRole('dialog', { name: '编辑模板' });
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '重新编辑' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenLastCalledWith(
'token',
template.id,
expect.objectContaining({ expectedRevision: 'rev-3', title: '重新编辑' }),
),
);
});
test('防止重复确认和保存中重复提交', async () => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(updateAdminAgcTemplate).mockReturnValue(pending.promise);
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
const form = within(editor).getByLabelText('名称').closest('form')!;
fireEvent.submit(form);
fireEvent.submit(form);
expect(screen.getAllByRole('dialog', { name: '确认操作' })).toHaveLength(1);
await confirmWrite();
await waitFor(() => expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1));
fireEvent.submit(form);
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
await act(async () => pending.resolve(library));
});
test.each([409, 503])(
'滚到表单底部后出现 HTTP %s 写入错误,聚焦并仅滚动弹窗到提示区',
async (status) => {
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
new AdminApiError({ message: '本次保存失败', status }),
);
render(
<div data-testid="background-page">
<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />
</div>,
);
const background = screen.getByTestId('background-page');
background.scrollTop = 240;
const editor = await openEditor();
const viewport = editor.querySelector<HTMLElement>(
'.genarrative-ui-modal__body',
)!;
viewport.scrollTop = 400;
const bounds = (top: number, bottom: number) =>
({
top,
bottom,
height: bottom - top,
left: 0,
right: 300,
width: 300,
x: 0,
y: top,
toJSON: () => ({}),
}) as DOMRect;
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(
bounds(100, 450),
);
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function (this: HTMLElement) {
return this.classList.contains('admin-agc-template-feedback')
? bounds(-280, -120)
: originalBounds.call(this);
},
);
const windowScroll = vi
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const focus = vi.spyOn(HTMLElement.prototype, 'focus');
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
expect(document.activeElement).toBe(feedback);
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);
expect(background.scrollTop).toBe(240);
expect(windowScroll).not.toHaveBeenCalled();
if (status === 409) {
expect(
within(feedback).getByRole('button', { name: '刷新后重新编辑' }),
).not.toBeNull();
}
},
);
test('token 切换后忽略旧列表和未确认写操作', async () => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(getAdminAgcTemplates).mockReturnValueOnce(pending.promise);
const props = { onUnauthorized: vi.fn() };
const view = render(<AdminAgcTemplatesPage token="old" {...props} />);
view.rerender(<AdminAgcTemplatesPage token="new" {...props} />);
await screen.findByText('空白 2D');
await act(async () =>
pending.resolve({
...library,
templates: [{ ...template, title: '过期列表' }],
}),
);
expect(screen.queryByText('过期列表')).toBeNull();
const editor = await openEditor();
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
view.rerender(<AdminAgcTemplatesPage token="third" {...props} />);
await screen.findByText('空白 2D');
expect(screen.queryByRole('dialog')).toBeNull();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test.each(['success', 'unauthorized'])(
'token 切换后忽略旧保存 %s,不污染新会话',
async (result) => {
const pending = deferred<AdminAgcTemplateLibraryResponse>();
vi.mocked(updateAdminAgcTemplate).mockReturnValueOnce(pending.promise);
const onUnauthorized = vi.fn();
const view = render(
<AdminAgcTemplatesPage token="old" onUnauthorized={onUnauthorized} />,
);
const editor = await openEditor();
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1),
);
view.rerender(
<AdminAgcTemplatesPage token="new" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('空白 2D');
await act(async () => {
if (result === 'success') {
pending.resolve({
...library,
templates: [{ ...template, title: '旧保存结果' }],
});
} else {
pending.reject(
new AdminApiError({ status: 401, message: '旧会话失效' }),
);
}
});
expect(onUnauthorized).not.toHaveBeenCalled();
expect(screen.queryByText('旧会话失效')).toBeNull();
expect(screen.queryByText('旧保存结果')).toBeNull();
expect(screen.queryByText('已保存')).toBeNull();
},
);
test('封面在草稿中转 base64,保存携带,取消释放预览 URL 且不串到下一条', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
let editor = await openEditor();
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
await waitFor(() => expect(screen.queryByText('正在读取封面')).toBeNull());
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
await confirmWrite();
await waitFor(() =>
expect(updateAdminAgcTemplate).toHaveBeenCalledWith(
'token',
template.id,
expect.objectContaining({
cover: { contentType: 'image/png', dataBase64: 'Y292ZXI=' },
}),
),
);
await waitFor(() =>
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:template-cover'),
);
editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '编辑 Godot 起步' }));
expect(
screen.getByRole('img', { name: '模板封面预览' }).getAttribute('src'),
).toBe(template.coverUrl);
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
});
test.each([
['bad.svg', 'image/svg+xml', 1, '封面仅支持 PNG、JPEG 或 WebP'],
[
'large.png',
'image/png',
5 * 1024 * 1024 + 1,
'封面文件须大于 0 且不超过 5 MiB',
],
])('拒绝无效封面 %s,不上传', async (name, type, size, message) => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
const file = new File(['x'], name, { type });
Object.defineProperty(file, 'size', { value: size });
fireEvent.change(within(editor).getByLabelText('更换封面'), {
target: { files: [file] },
});
expect(screen.getByText(message)).not.toBeNull();
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('名称和标签校验失败时不进入写确认', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const editor = await openEditor();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: ' ' },
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(screen.getByText('名称须为 180 个字符')).not.toBeNull();
fireEvent.change(within(editor).getByLabelText('名称'), {
target: { value: '有效名称' },
});
fireEvent.change(within(editor).getByLabelText('标签'), {
target: {
value: Array.from({ length: 17 }, (_, index) => `标签${index}`).join(','),
},
});
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
expect(
screen.getByText('最多 16 个标签,每个标签不超过 32 个字符'),
).not.toBeNull();
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
});
test('读取失败显示真实错误,401 交给会话处理且不显示空库', async () => {
const onUnauthorized = vi.fn();
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({ status: 503, message: '模板服务不可用' }),
);
render(
<AdminAgcTemplatesPage token="token" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('模板服务不可用');
expect(screen.queryByText('暂无模板')).toBeNull();
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '会话失效' }),
);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
File diff suppressed because it is too large Load Diff
+81
View File
@@ -13,6 +13,87 @@
text-rendering: optimizeLegibility;
}
.admin-agc-templates {
min-width: 0;
}
.admin-agc-template-filters {
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
gap: 14px;
}
.admin-agc-template-table {
min-width: 850px;
}
.admin-agc-template-table td:nth-child(2) {
min-width: 230px;
max-width: 380px;
}
.admin-agc-template-cover {
display: block;
width: 88px;
height: 62px;
border-radius: 8px;
object-fit: cover;
background: #f8efe7;
}
.admin-agc-template-summary {
display: -webkit-box;
overflow: hidden;
margin: 8px 0;
color: #866954;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.admin-agc-template-tags,
.admin-agc-template-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-agc-template-tags span {
padding: 3px 7px;
border-radius: 5px;
background: #f8efe7;
font-size: 12px;
}
.admin-agc-template-dialog {
border-radius: 10px;
}
.admin-agc-template-editor,
.admin-agc-template-conflict {
display: grid;
gap: 14px;
}
.admin-agc-template-cover-preview {
display: block;
width: min(100%, 300px);
max-height: 180px;
border-radius: 8px;
object-fit: contain;
background: #f8efe7;
}
.admin-agc-template-dialog .admin-confirm-backdrop {
z-index: 1100;
}
@media (max-width: 680px) {
.admin-agc-template-filters {
grid-template-columns: minmax(0, 1fr);
}
}
* {
box-sizing: border-box;
}
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
args: [
'cp',
'--force',
'/tmp/a.json',
'oss://agc-dev/agc/global-version.json',
],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',
@@ -287,6 +287,13 @@ try {
]) {
assert.ok(fs.existsSync(path.join(plugin, file)), file);
}
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
// 还要放行它的上级目录 `game-runtime/node/node_modules`recursive readdir 会列出目录项,
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
const allowedNodeModules = (file) =>
file === 'game-runtime/node/node_modules' ||
file === 'game-runtime/node/node_modules/npm' ||
file.startsWith('game-runtime/node/node_modules/npm/');
const packageFiles = fs.readdirSync(resources, { recursive: true });
assert.ok(
!packageFiles.some(
@@ -294,8 +301,7 @@ try {
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
file,
) ||
(/(^|\/)node_modules(\/|$)/.test(file) &&
!file.startsWith('game-runtime/node/node_modules/npm')),
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
),
);
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
@@ -1192,7 +1192,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
@@ -1006,16 +1006,58 @@ pub(crate) async fn external_editor_json_request(
let response = crate::http_client::with_agc_main_site_marker(request)
.send()
.await
.map_err(|error| format!("{action}失败:{error}"))?;
.map_err(|error| {
format!(
"{action}失败:{}",
describe_external_request_failure(&error)
)
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format_external_http_error(action, status, &body));
}
response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析{action}响应失败:{error}"))
response.json::<serde_json::Value>().await.map_err(|error| {
format!(
"解析{action}响应失败:{}",
describe_external_request_failure(&error)
)
})
}
/// 把 reqwest 失败翻译成可现场定责的文案。
///
/// `reqwest::Error` 的 `Display` 只输出 kind:客户端预算内没读完正文(总超时)、
/// 正文被提前截断和正文不是合法 JSON 都会显示成同一句 `error decoding response body`
/// 现场无法区分是平台慢、链接慢还是响应被截断。这里补上 kind 语义与底层因链;
/// 因链只取错误文本,不拼接 URL,避免把绝对地址写进日志。
fn describe_external_request_failure(error: &reqwest::Error) -> String {
let kind = if error.is_timeout() {
"请求超时(连接、响应头或响应正文未在客户端预算内完成)"
} else if error.is_decode() {
"响应正文未完整返回或不是合法 JSON"
} else if error.is_body() {
"响应正文读取失败"
} else if error.is_connect() {
"连接失败"
} else if error.is_request() {
"请求发送失败"
} else {
"请求失败"
};
let mut causes: Vec<String> = Vec::new();
let mut source = std::error::Error::source(error);
while let Some(current) = source {
let text = current.to_string();
if !text.is_empty() && !causes.contains(&text) {
causes.push(text);
}
source = current.source();
}
if causes.is_empty() {
return kind.to_string();
}
format!("{kind}{}", causes.join(""))
}
/// Keep provider validation details useful to the operator without copying an
@@ -1443,10 +1485,14 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let project_id = if let Some(project_id) = partial.remote_project_id.clone() {
project_id
} else {
// 这一步只需要按 projectId 确认绑定项目是否仍然存在,固定用摘要视图:
// 缺省 full 会把账号下每个项目的画布与全量资源都带回来,项目增长后会耗尽本次请求的
// 客户端预算,现场表现为「解析读取外部画布项目响应失败:error decoding response body」。
// 站内 `/api/editor/projects` 与 `/api/external/v1/editor/projects` 都支持该视图。
let projects_payload = external_editor_json_request(
client
.get(format!(
"{}{}",
"{}{}?view=summary",
access.api_base_url(),
access.api_route("/api/external/v1/editor/projects")
))
@@ -8535,6 +8581,28 @@ mod canvas_generation_tests {
);
}
/// reqwest 的 `Display` 只给 kind,超时 / 正文截断 / 非法 JSON 全都是同一句
/// `error decoding response body`。这个用例钉住「至少把 kind 语义换成人话」,
/// 避免再退回无法定责的原始文案。
#[tokio::test]
async fn external_request_failure_replaces_the_opaque_reqwest_kind() {
let error = reqwest::Client::new()
.get("http://127.0.0.1:1/healthz")
.timeout(Duration::from_secs(2))
.send()
.await
.expect_err("closed port must fail");
let described = describe_external_request_failure(&error);
// 该端口在真实环境里可能被直接拒绝、也可能被中间层吞掉直到超时,两种都必须能定责。
assert!(
described.contains("连接失败") || described.contains("请求超时"),
"{described}"
);
assert!(described.contains(""), "必须补上底层因链:{described}");
assert!(!described.contains("error sending request"), "{described}");
assert!(!described.contains("http://127.0.0.1:1"), "{described}");
}
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
@@ -8843,7 +8911,8 @@ mod canvas_generation_tests {
}),
);
false
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
} else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
{
write_test_json_response(
&mut stream,
"200 OK",
@@ -9213,7 +9282,7 @@ mod canvas_generation_tests {
.expect("write concurrent generation png body");
return;
}
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
write_test_json_response(
stream,
@@ -9687,7 +9756,7 @@ mod canvas_generation_tests {
"Bearer token-b" => "b",
unexpected => panic!("unexpected authorization {unexpected}"),
};
let response = if request.starts_with("GET /api/editor/projects ") {
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let projects = if project_created.get(account).copied().unwrap_or(false) {
vec![serde_json::json!({
"projectId": format!("remote-project-{account}"),
@@ -9853,7 +9922,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture concurrent binding request");
let response = if request.starts_with("GET /api/editor/projects ") {
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
let (state_lock, ready) = &*state;
let mut state = state_lock.lock().expect("lock project fixture state");
if !state.project_created {
@@ -9988,7 +10057,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture project partial request");
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10122,7 +10191,7 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture frozen binding request");
if request.starts_with("GET /api/external/v1/editor/projects ") {
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
write_test_json_response(
&mut stream,
"200 OK",
@@ -10247,23 +10316,24 @@ mod canvas_generation_tests {
request_sender
.send(request.clone())
.expect("capture folder partial request");
let response = if request.starts_with("GET /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
let response =
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
serde_json::json!({"data": {"projects": []}})
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
serde_json::json!({"data": {"project": {
"projectId": "folder-partial-project"
}}})
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
serde_json::json!({"data": {"library": {"folders": []}}})
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
fs::create_dir_all(&blocked_binding_path)
.expect("block final binding write after folder creation");
serde_json::json!({"data": {"folder": {
"folderId": "folder-partial-folder"
}}})
} else {
panic!("unexpected folder partial request: {request}");
};
write_test_json_response(&mut stream, "200 OK", &response);
}
});
@@ -862,11 +862,22 @@ fn resource_edit_state_sha256<T: Serialize>(value: &T, label: &str) -> Result<St
.map_err(|error| format!("序列化{label}身份失败:{error}"))
}
fn ensure_resource_edit_phase_resumable(phase: &ResourceEditLedgerPhase) -> Result<(), String> {
match phase {
ResourceEditLedgerPhase::RemoteFailed => {
Err("remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求".to_string())
}
/// 只有仍可继续的阶段允许再次请求远端;终态必须在这里失败关闭。
///
/// 终态文案要带出稳定失败码和唯一出口(移出恢复队列):否则用户只会看到一句
/// 「已明确失败」并反复点重试。上游原文不进账本(见 `terminal_failure_code` 的写入边界),
/// 这里只透传分类码,不做二次解释。
fn ensure_resource_edit_phase_resumable(ledger: &ResourceEditLedger) -> Result<(), String> {
match ledger.phase {
ResourceEditLedgerPhase::RemoteFailed => Err(format!(
"remote-terminal-failed: 远端资源编辑已明确失败({}),不允许再次请求;如需重试,请先在待恢复资源编辑中把它移出恢复队列",
ledger
.terminal_failure_code
.as_deref()
.map(str::trim)
.filter(|code| !code.is_empty())
.unwrap_or("remote-failed")
)),
ResourceEditLedgerPhase::Archived => {
Err("resource-edit-archived: 资源编辑已移出恢复队列".to_string())
}
@@ -5122,7 +5133,7 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at(
{
return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string());
}
ensure_resource_edit_phase_resumable(&ledger.phase)?;
ensure_resource_edit_phase_resumable(&ledger)?;
let (api_base_url, api_key, platform_session) =
resolve_canvas_sync_api_credentials(None, None)?;
match prepare_resource_edit_service_identity(
@@ -5195,7 +5206,7 @@ fn confirm_resource_edit_service_identity_under_lease(
return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string());
}
validate_resource_edit_service_identity_owner(&ledger, platform_session)?;
ensure_resource_edit_phase_resumable(&ledger.phase)?;
ensure_resource_edit_phase_resumable(&ledger)?;
let confirmation = ledger
.service_identity_confirmation
.clone()
@@ -5321,7 +5332,7 @@ pub(crate) async fn resume_local_project_resource_edit_at(
{
return Err("待恢复的资源编辑账本身份无效".to_string());
}
ensure_resource_edit_phase_resumable(&ledger.phase)?;
ensure_resource_edit_phase_resumable(&ledger)?;
let source_asset = ledger
.source_asset_id
.as_deref()
@@ -5552,7 +5563,7 @@ pub(crate) async fn derive_local_project_resource_at(
{
return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string());
}
ensure_resource_edit_phase_resumable(&ledger.phase)?;
ensure_resource_edit_phase_resumable(&ledger)?;
ledger
}
None => {
@@ -6157,8 +6168,8 @@ mod tests {
let request = read_http_request(&mut stream);
let request_line = request.lines().next().unwrap_or_default();
requests.push(request.clone());
if request_line.starts_with("GET /api/external/v1/editor/projects ")
|| request_line.starts_with("GET /api/editor/projects ")
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request_line.starts_with("GET /api/editor/projects?view=summary ")
{
write_json(
&mut stream,
@@ -6686,7 +6697,9 @@ mod tests {
let request = read_http_request(&mut stream);
sender.send(request.clone()).expect("capture B request");
let request_line = request.lines().next().unwrap_or_default();
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
if request_line
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
{
write_json(
&mut stream,
"200 OK",
@@ -7186,7 +7199,7 @@ mod tests {
);
assert!(requests.iter().all(|request| {
let line = request.lines().next().unwrap_or_default();
line.starts_with("GET /api/external/v1/editor/projects ")
line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| line.starts_with("GET /api/external/v1/editor/assets/library ")
}));
}
@@ -9293,7 +9306,7 @@ mod tests {
.expect("capture External video request");
let request_line = request.lines().next().unwrap_or_default();
let request_lower = request.to_ascii_lowercase();
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
write_json(
&mut stream,
"200 OK",
@@ -391,8 +391,8 @@ fn assert_developer_submission(request: &str) {
fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool {
let line = request.lines().next().unwrap_or_default();
if line.starts_with("GET /api/external/v1/editor/projects ")
|| line.starts_with("GET /api/editor/projects ")
if line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| line.starts_with("GET /api/editor/projects?view=summary ")
{
write_json(
stream,
@@ -938,6 +938,24 @@ pub(crate) fn create_project_from_installed_template_at(
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
copy_template_project(installed_project_dir, &project_root)?;
if discover_local_cocos_project_root(&project_root)?.is_some() {
let package_path = project_root.join("package.json");
let mut package: serde_json::Value = serde_json::from_slice(
&fs::read(&package_path)
.map_err(|error| format!("读取 Cocos 项目配置失败:{error}"))?,
)
.map_err(|error| format!("解析 Cocos 项目配置失败:{error}"))?;
package["name"] = serde_json::json!(project_name);
package["uuid"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
let bytes = serde_json::to_vec_pretty(&package)
.map_err(|error| format!("序列化 Cocos 项目配置失败:{error}"))?;
write_game_creator_private_file(&package_path, &bytes, "Cocos 项目配置")?;
return import_local_cocos_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
);
}
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
@@ -1182,6 +1200,39 @@ mod tests {
assert!(error.contains("重复模板"), "{error}");
}
#[test]
fn parses_content_addressed_objects_without_changing_the_template_contract() {
let mut index: serde_json::Value =
serde_json::from_str(&sample_index_body()).expect("sample index");
let keys = [
("zipKey", "template.zip", "a"),
("coverKey", "cover.png", "b"),
("metadataKey", "template.json", "c"),
]
.map(|(field, file, hash)| {
(
field,
format!(
"templates/v1/demo-template/sha256/{}/{file}",
hash.repeat(64)
),
)
});
for (field, key) in &keys {
index["templates"][0][field] = serde_json::json!(key);
}
let (_, templates) =
parse_game_template_library_index(&index.to_string()).expect("content addressed index");
let parsed = serde_json::to_value(&templates[0]).expect("serialize template");
for (field, key) in &keys {
assert_eq!(parsed[field], *key);
assert!(template_object_url(key)
.expect("trusted URL")
.ends_with(key));
}
assert_eq!(templates[0].template_version, "1.0.0");
}
#[test]
fn rejects_object_keys_outside_the_templates_prefix() {
assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err());
@@ -1225,6 +1276,10 @@ mod tests {
"blank-2d-canvas",
"blank-3d-scene",
"blank-web",
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
"phaser-2d-starter",
"threejs-3d-starter",
]
@@ -1360,6 +1415,142 @@ mod tests {
assert!(error.contains("模板尚未安装"), "{error}");
}
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(directory).expect("read template directory") {
let path = entry.expect("template entry").path();
if path.is_dir() {
collect(root, &path, files);
} else {
files.push((
path.strip_prefix(root)
.expect("relative path")
.to_string_lossy()
.replace('\\', "/"),
fs::read(&path).expect("read template file"),
));
}
}
}
let mut files = Vec::new();
collect(root, root, &mut files);
files.sort_by(|left, right| left.0.cmp(&right.0));
files
}
fn assert_cocos_template_creates_independent_projects(
summary: &GameTemplateSummary,
archive: &[u8],
) {
let cache_root = tempfile::tempdir().expect("cache directory");
let projects_root = unique_projects_root();
let record = install_template_archive(cache_root.path(), summary, archive)
.expect("install Cocos template");
let installed_root = Path::new(&record.project_dir);
let installed_files = template_source_files(installed_root);
let original_package: serde_json::Value = serde_json::from_slice(
&fs::read(installed_root.join("package.json")).expect("installed package"),
)
.expect("parse installed package");
let mut project_uuids = std::collections::HashSet::new();
for name in ["Cocos 模板项目一", "Cocos 模板项目二"] {
let created = create_project_from_installed_template_at(
&projects_root,
installed_root,
Some(name),
false,
)
.expect("create Cocos project from template");
let root = Path::new(&created.project_path);
assert_eq!(created.manifest.name, name);
assert_eq!(created.manifest.cocos_project_root.as_deref(), Some("."));
assert!(created.manifest.godot_project_root.is_none());
assert!(root.join(".agent/manifest.json").is_file());
assert!(root.join(".agent/agent.db").is_file());
for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] {
assert!(!root.join(unexpected).exists(), "unexpected {unexpected}");
}
let package: serde_json::Value = serde_json::from_slice(
&fs::read(root.join("package.json")).expect("created package"),
)
.expect("parse created package");
assert_eq!(package["name"], name);
assert_eq!(package["creator"]["version"], "3.8.8");
let uuid = package["uuid"].as_str().expect("project uuid");
uuid::Uuid::parse_str(uuid).expect("valid project uuid");
assert_ne!(package["uuid"], original_package["uuid"]);
assert!(project_uuids.insert(uuid.to_string()));
for (relative, bytes) in &installed_files {
if relative != "package.json" && relative != TEMPLATE_INSTALLED_MARKER_FILE {
assert_eq!(&fs::read(root.join(relative)).expect("copied file"), bytes);
}
}
}
assert_eq!(template_source_files(installed_root), installed_files);
fs::remove_dir_all(&projects_root).expect("remove test projects");
}
#[test]
fn installs_official_cocos_templates_and_creates_native_projects() {
let library = Path::new(env!("CARGO_MANIFEST_DIR")).join("../template-library/v1");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let source = library.join(id).join("project");
let source_files = template_source_files(&source);
let entries = source_files
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect::<Vec<_>>();
let archive = build_archive(&entries);
let mut summary = sample_summary();
summary.id = id.to_string();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
assert_cocos_template_creates_independent_projects(&summary, &archive);
assert_eq!(template_source_files(&source), source_files);
}
}
#[tokio::test]
#[ignore = "需要网络:下载线上 Cocos 模板并验证原生建项"]
async fn downloads_and_creates_live_cocos_templates() {
let base = template_library_base().expect("trusted base");
let client = build_template_library_client();
let body = fetch_limited_bytes(
&client,
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
)
.await
.expect("fetch live index");
let (_, templates) =
parse_game_template_library_index(&String::from_utf8(body).expect("utf-8 index"))
.expect("parse live index");
for id in [
"cocos-empty-2d",
"cocos-empty-3d",
"cocos-empty-3d-hq",
"cocos-hello-world",
] {
let summary = templates.iter().find(|entry| entry.id == id).expect(id);
assert_eq!(summary.runtime, "cocos");
assert_eq!(summary.engine_version, "3.8.8");
assert_eq!(summary.entry, "package.json");
let bytes = fetch_limited_bytes(
&client,
&template_object_url(&summary.zip_key).expect("trusted zip url"),
TEMPLATE_ARCHIVE_MAX_BYTES,
)
.await
.expect("download Cocos template");
assert_cocos_template_creates_independent_projects(summary, &bytes);
}
}
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
#[cfg(not(feature = "template-library-fixtures"))]
#[test]
@@ -3337,8 +3337,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
}
let normalized_request = request.to_ascii_lowercase();
let (status, content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request.starts_with("GET /api/editor/projects?view=summary ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", projects_body.as_bytes().to_vec())
@@ -3564,8 +3564,8 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
let (status, body) = match index {
0 => {
assert!(
request.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|| request.starts_with("GET /api/editor/projects?view=summary ")
);
(
"200 OK",
@@ -2,18 +2,13 @@
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T03:22:43Z",
"updatedAt": "2026-09-17T13:55:10Z",
"templates": [
{
"id": "blank-2d-canvas",
"title": "空白二维画布工程",
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
"tags": [
"空白",
"起步工程",
"2d",
"canvas"
],
"tags": ["空白", "起步工程", "2d", "canvas"],
"runtime": "html",
"engine": "canvas",
"engineVersion": "",
@@ -33,12 +28,7 @@
"id": "blank-3d-scene",
"title": "空白三维场景工程",
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
"tags": [
"空白",
"起步工程",
"3d",
"three.js"
],
"tags": ["空白", "起步工程", "3d", "three.js"],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
@@ -58,12 +48,7 @@
"id": "blank-web",
"title": "空白网页工程",
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
"tags": [
"空白",
"起步工程",
"网页",
"原生"
],
"tags": ["空白", "起步工程", "网页", "原生"],
"runtime": "html",
"engine": "none",
"engineVersion": "",
@@ -79,16 +64,91 @@
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
"metadataKey": "templates/v1/blank-web/template.json"
},
{
"id": "cocos-empty-2d",
"title": "Cocos 空白 2D 工程",
"summary": "官方二维空白模板,保留精灵导入默认值与二维编辑视图。",
"tags": ["空白", "起步工程", "2d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-2d/template.zip",
"zipSizeBytes": 1976,
"zipSha256": "e30bd6324fd2eb721c9a9fdcc9d8bb9838fba7b3413d09a890d3152c3576c485",
"coverKey": "templates/v1/cocos-empty-2d/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "4ca21e32e890e8a3d20bde2b9f1228993cbd770532c496433de0e5ffecc31342",
"metadataKey": "templates/v1/cocos-empty-2d/template.json"
},
{
"id": "cocos-empty-3d",
"title": "Cocos 空白 3D 工程",
"summary": "官方三维空白模板,适合从零搭建 Cocos 场景与玩法。",
"tags": ["空白", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-3d/template.zip",
"zipSizeBytes": 829,
"zipSha256": "e4016ed9bcd3d3b09d5d06e776c2cf4d98415473b3747bc4945e06e93967117a",
"coverKey": "templates/v1/cocos-empty-3d/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "88111f77f6801a77acc05277dc2698378ce9eeb8e9265b7d2dedafde0d893aae",
"metadataKey": "templates/v1/cocos-empty-3d/template.json"
},
{
"id": "cocos-empty-3d-hq",
"title": "Cocos 高质量 3D 工程",
"summary": "官方高质量三维空白模板,保留天空盒、阴影与线性纹理采样预设。",
"tags": ["空白", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-empty-3d-hq/template.zip",
"zipSizeBytes": 1226,
"zipSha256": "4b4f12a7dd39be31bc471c7387b272717e9476b1a6eafb21273ce1a3ebc38384",
"coverKey": "templates/v1/cocos-empty-3d-hq/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "276752f234c1209c182ca79b65d97c1d52d9e22b60be9aaee6cb0b134ab666ea",
"metadataKey": "templates/v1/cocos-empty-3d-hq/template.json"
},
{
"id": "cocos-hello-world",
"title": "Cocos Hello World",
"summary": "官方三维示例场景,包含岛屿、角色、植被、材质与天空盒资源。",
"tags": ["示例", "起步工程", "3d", "cocos"],
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T13:55:10Z",
"entry": "package.json",
"zipKey": "templates/v1/cocos-hello-world/template.zip",
"zipSizeBytes": 2484948,
"zipSha256": "dac8cc2eae2ec9ff3cd429a1be2c2981b06919be720bfa50226619c0a56e00da",
"coverKey": "templates/v1/cocos-hello-world/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "9363b728a792835f57d22d129c598449801d343f739e85159e83e531ba7d949c",
"metadataKey": "templates/v1/cocos-hello-world/template.json"
},
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"tags": ["起步工程", "2d", "phaser", "像素"],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
@@ -108,12 +168,7 @@
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"tags": ["起步工程", "3d", "three.js", "网页"],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",

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