Compare commits

..

91 Commits

Author SHA1 Message Date
k88936 97b0a4b9b8 更新测试用例以反映按钮名称从 "gpt-image-2" 修改为 "GPT Image 2.5"
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m50s
Project CI / Backend tests (pull_request) Successful in 5m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m17s
Project CI / Native shell tests (pull_request) Successful in 7m0s
Project CI / Frontend tests (pull_request) Failing after 3m41s
Project CI / Repository checks (pull_request) Successful in 3m16s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m51s
2026-09-21 22:09:17 +08:00
k88936 e3ba986596 修正 OpenAPI 中 model 字段与实现不一致的描述
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (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 lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
- EditorImageGenerationRequest.model 说明 kind=ui-design 与 publication-material 固定使用 gpt-image-2.5 并忽略传入 model,其余 kind 未传时默认 gpt-image-2.5
- EditorUiDesignAssetExtractionRequest.model 说明未传时默认 nanobanana,传 gpt-image-2.5 时按 GPT Image 2.5 编辑任务执行
2026-09-21 22:02:44 +08:00
k88936 69af571242 修订生成带参考图的模型路由与计价文档
- ADR 改为「带参考图的生成与编辑都按编辑 concrete model 执行、审计与计价」,并补定价档位跟随 concrete model 的后果
- 里程碑文档路由表拆分 flare-c(仅无参考图生成)与 sunburst-c(编辑 + 带参考图的生成),必须成立的行为补第 7 条
- decision-log 修正 2026-09-18 条目,并新增本次「生成带参考图统一按编辑 concrete model 执行与计价」决策记录
- pitfalls、后端架构与开发运维文档同步 provider 具体模型与计价档位口径
2026-09-21 22:02:05 +08:00
k88936 166977baf0 图片素材脚本改用 concrete model 与 Tiantoken 凭据
- generate-template-samples 与 generate-anthro-cat-illustrations 的 preferredImageModel 改为 gpt-image-2.5-flare-c
- 两个脚本的 base URL / API key / 超时改为只读 TIANTOKEN_BASE_URL、TIANTOKEN_API_KEY、TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS,不再回退 VectorEngine
- 脚本内 URL 构造函数、错误文案与错误字段名从 VectorEngine 改为 provider 中性的 Tiantoken 口径
- SKILL.md 请求契约、环境变量与模型说明同步到 concrete model + Tiantoken,并说明带参考图的编辑走 gpt-image-2.5-sunburst-c
2026-09-21 22:01:44 +08:00
k88936 5c58830545 删除仅测试使用的图片编辑封装
- 删除 create_openai_image_edit 与 create_openai_image_edit_with_references 两个 dead_code 封装
- 空参考图本地校验用例改走 create_openai_image_edit_with_references_and_model,保留原覆盖
2026-09-21 22:01:21 +08:00
k88936 14c2255d54 补 provider 白名单的历史值断言
- resolve_image_provider 单测增加 gpt-image-2 → Tiantoken 断言,钉死 ADR 记录的历史可读值意图
2026-09-21 22:00:58 +08:00
k88936 f8f0479488 图片兜底 task id 前缀跟随实际 provider
- executor 新增 image_task_id_prefix,按 settings.provider 拼接前缀
- 生成、nanobanana 生成与编辑三处不再硬编码 vector-engine 前缀,Tiantoken 任务的持久化与审计不再带错误前缀
2026-09-21 22:00:40 +08:00
k88936 a06c7d7f1e raw image edit 发送前复用共享 provider 白名单校验
- platform-image 导出 ensure_provider_matches_model 供 crate 内复用
- create_raw_image_edit 在构造请求前校验 settings.provider 与固定的编辑 concrete model 一致
2026-09-21 22:00:21 +08:00
k88936 c38e4b4175 生成带参考图统一按编辑 concrete model 执行与计价
- 新增 editor_image_generation_concrete_model 作为生成类任务选择 concrete model 的唯一入口,dispatch 与计价共用同一判据
- image_generation_mud_points 增加 has_reference_images,命中编辑 concrete model 时改走 image_edit_model_mud_points
- editor_project/editor_project_icon 的 provider_model 改用共享解析函数,替换三处重复的带参考图 if 表达式
- character_visual_assets 的角色主形象生成改用共享解析函数
- 图标素材图集、UI 素材提取、图标规范带 referenceId、Agent 生成类工具带 referenceImageIds 的计价同步切到编辑档
- EditImageTool 计价从生成档改为编辑档,与直连快速编辑、raw edit 对齐
- normalize_editor_generation_options 的具体 provider key 改用常量替换字符串字面量
- 补充参考图档位路由与 Agent 工具档位路由单测
2026-09-21 22:00:08 +08:00
k88936 f3c362b1ec 整体删除已退役的 gpt-image-2-c
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / Backend tests (pull_request) Successful in 5m4s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m30s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m48s
Project CI / Frontend tests (pull_request) Failing after 3m14s
Project CI / Repository checks (pull_request) Successful in 2m46s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m24s
- 删除 platform-image 的 GPT_IMAGE_2_C_MODEL 常量、re-export、尺寸语义与审计标签
- 移除 api-server 三处任务边界对 gpt-image-2-c 的兼容解析分支
- 移除主站前端历史模型常量与恢复分支,并同步其测试
- 同步 External v1 OpenAPI 描述、ADR、CONTEXT、pitfalls 与后端/运维文档
- 数据库既有 gpt-image-2-c 审计字符串保持原样,不回写不改写
2026-09-21 20:37:43 +08:00
k88936 fc53f362e4 同步图片 provider 路由白名单与启动日志文档
ADR 补充业务名与 gpt-image-2-c 的拒绝规则

决策记录补充启动报错点名环境变量与启动日志
2026-09-21 20:37:39 +08:00
k88936 8988e91ee9 补全图片 provider 启动报错与日志信息
缺少 BASE_URL / API_KEY 时点名对应环境变量

启动成功时记录 provider、base_url 与超时配置
2026-09-21 20:37:21 +08:00
k88936 784f38220b 图片 provider 路由拒绝已退役的 gpt-image-2-c
gpt-image-2-c 仅保留历史 fallback 与审计字符串

resolve_image_provider 白名单不再接受该值

补充拒绝断言
2026-09-21 20:37:21 +08:00
k88936 44798ba380 收紧图片 provider 路由白名单拒绝业务模型名
resolve_image_provider 不再接受 gpt-image-2.5 业务名

业务名必须先在任务边界解析成 flare-c / sunburst-c 具体 key

新增白名单回归测试
2026-09-21 20:37:21 +08:00
k88936 2d679122cf 合并前端历史图片模型判定与归一
抽取 LEGACY_EDITOR_IMAGE_MODELS 作为单一来源

normalizeEditorImageModel 与 isLegacyEditorImageModel 复用该集合
2026-09-21 20:37:21 +08:00
k88936 4cbbcecaa2 减少图片工具参数归一的冗余 JSON 拷贝
normalize_legacy_image_model_args 改为按需返回 Cow

未命中归一时复用原始参数视图,避免多一次深拷贝
2026-09-21 20:37:21 +08:00
k88936 98bfa5efd0 按模型解析图片编辑缺失参考图的 provider
create_openai_image_edit_with_references_and_model 错误归属与路由一致
2026-09-21 20:37:21 +08:00
k88936 ab472b4562 删除无调用方的旧图片生成封装
移除 create_openai_image_generation 及其未再用到的导入
2026-09-21 20:37:21 +08:00
k88936 1a1f124acd 公开定价投影隐藏旧 GPT Image 2-C 别名
public_projection 同步移除 gpt-image-2-c 键
2026-09-21 20:37:21 +08:00
k88936 4dc199cf33 修正 nanobanana 端点空模型的回落值
nanobanana_generate_content_url 空模型回落到 nanobanana 模型

新增空模型路径的单元测试
2026-09-21 20:37:21 +08:00
k88936 ef34e0ed64 统一 nanobanana 执行器的 provider 标签
取消 create_nanobanana_generate_content 中硬编码的 VectorEngine

错误、重试与审计统一读取 settings.provider
2026-09-21 20:37:21 +08:00
k88936 786898f7f8 移除模板样例试运行结果的空回退字段
干运行输出只保留实际请求信息
2026-09-21 20:37:21 +08:00
k88936 e880f58723 移除猫插画试运行结果的空回退字段
干运行输出只保留实际请求信息
2026-09-21 20:37:21 +08:00
k88936 6f19e155ca 清理模板样例脚本的单模型请求循环
移除单元素模型循环和无操作异常捕获

保留原有请求与错误语义
2026-09-21 20:37:21 +08:00
k88936 6e9f991a76 清理猫插画脚本的单模型请求循环
移除单元素模型循环和无操作异常捕获

保留原有请求与错误语义
2026-09-21 20:37:21 +08:00
lhk229 a6f99c8871 Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m32s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m27s
Project CI / Backend tests (pull_request) Successful in 5m36s
Project CI / Native shell tests (pull_request) Successful in 8m0s
Project CI / Frontend tests (pull_request) Failing after 4m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m55s
Project CI / Repository checks (pull_request) Successful in 4m7s
2026-09-21 19:47:37 +08:00
lhk229 2d61a4c057 Merge pull request 'Codex/fix direct image generation ci' (#452) from codex/fix-direct-image-generation-ci into master
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
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/452
2026-09-21 19:46:05 +08:00
lhk229 e2bdb1105b 合并最新 master 到图片生成 CI 修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 5m5s
Project CI / Frontend tests (pull_request) Successful in 5m40s
Project CI / Backend tests (pull_request) Successful in 8m16s
Project CI / Native shell tests (pull_request) Successful in 9m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m45s
合入最新 master 的客户端图标素材描述修复
保留图片生成通知测试的宿主执行会话初始化与失败断言
2026-09-21 11:20:53 +00:00
lhk229 71f5f36764 修复图片生成通知测试缺失宿主执行会话
复用真实宿主执行会话与交付合同夹具,补齐 Direct 工具桥测试前置状态
保留图片资源提交后的刷新通知断言,并核对空提示词拒绝原因
同步更新图片生成集成测试初始化说明
2026-09-21 11:15:39 +00:00
lhk229 f71361c292 修复客户端图标素材生成丢失用户描述 (#450)
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
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
删除图标生成的固定提示词包装、默认美术说明和两次截断
按服务端单条描述语义校验非空及200个Unicode字符,超限拒绝并保留原文
保留内部换行,直接以单条iconDescriptions提交用户描述
更新前端、原生入口和实际HTTP请求回归测试
同步工具说明、生成契约及项目排障文档

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/450
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-21 19:12:35 +08:00
lhk229 6e9e7edcf8 修复回显测试身份数据与 Linux 条件编译
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m12s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web 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 / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) 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
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
按实际用户输入生成 SHA-256 请求摘要,保留回显去重与历史记录断言
保留 Windows 安装身份辅助函数的平台限制,补齐两处 AppData 调用分支的条件编译
同步 Direct 回合测试与安装路径策略文档
2026-09-21 10:59:28 +00:00
lhk229 480fe995ed Merge remote-tracking branch 'origin/master' into codex/fix-agc-command-runtime-ci 2026-09-21 10:43:06 +00:00
lhk229 afd5b8d91f 修复 Linux 命令执行将孤儿僵尸误判为未回收进程
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m39s
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 lane 2/2 (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
命令主进程退出后区分存活成员与僵尸成员,保留进程组身份校验和失败关闭语义
处理 procfs 扫描期间进程退出及非 UTF-8 进程名,空进程组直接返回
新增隔离 subreaper 回归测试并补全命令 observation 断言诊断
同步 Runtime 技术方案与共享排障记录
2026-09-21 10:30:41 +00:00
kdletters c60d116af0 合并 codex/agc-godot-templates-20260921 到 master:新增 Godot 模板与按模板建项分流
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
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- 新增 Godot 模板源 godot-empty-2d / godot-empty-3d / godot-hello-world / godot-platformer-2d

- 按模板建项新增 Godot 分流:改写 project.godot 的 config/name 后走既有 Godot 导入并写入相对根

- Godot 工作区发现放宽:一层多命中按目录名排序取首个,project.godot 允许链接,内置插件行去掉手动启动

- 解决 decision-log.md 冲突:保留渠道安装身份与项目快照按渠道分区两条记录,并合入 Godot 两条记录
2026-09-21 18:17:33 +08:00
kdletters 3eb99f5124 合并 codex/agc-channel-install-identity 到 master:AGC 渠道安装身份隔离
- AGC 渠道安装身份隔离:不同渠道包体可在同一台设备并存
2026-09-21 18:17:12 +08:00
kdletters 34b1ad5d5c 新增 Godot 模板并补按模板建项的 Godot 分流
- 新增 4 个仓库内手写 Godot 4.7 模板源:godot-empty-2d、godot-empty-3d、godot-hello-world、godot-platformer-2d,entry 统一为 project.godot,各自带 meta.json 与 960×540 封面
- template_library.rs:复制模板后按 project.godot 识别 Godot 模板,改写工程显示名后走既有 Godot 导入并写入 godotProjectRoot,不再落到 Web 占位入口
- manifest.rs:新增 apply_godot_project_display_name,只改 [application] 段的 config/name 一行,其余字节逐字保留
- manifest.rs:放宽 Godot 工作区发现,一层子目录多命中按目录名排序取第一个,project.godot 允许是符号链接 / reparse point / 硬链接
- commands.rs:同步 Godot 导入错误文案,去掉「普通文件」限定
- RuntimeConfigDialog.tsx:内置插件行去掉手动启动 / 停止按钮,启动由前端自动启动承担、停止走该行启用开关
- import_tests.rs 与 tests/project.rs:补显示名改写、多命中确定性、链接标记与错误文案的定向回归
- 同步更新 PRD、模板包组织指南、模板库与模板建项技术方案、AGC Godot 编辑器插件接入方案、实施计划与共享决策记录
2026-09-21 17:25:34 +08:00
kdletters c52e0814d6 AGC渠道安装身份隔离:不同渠道包体可在同一台设备并存
- 新增 channel-identity.mjs 单点定义渠道安装身份,默认渠道保持基线 productName 与 identifier
- build-release.mjs 渠道 --config 同批注入 productName、identifier 与 updater 端点
- build-macos-ci.mjs 按发布渠道解析产品名,派生 .app、updater 归档与 DMG 名称
- main.rs 与 windows.rs 窗口标题取构建期产品名,同机并存的渠道客户端可区分
- config.rs 提权 ACL 的 managed 范围按目录名识别基线目录与渠道派生目录
- check-config.mjs 断言基线等于默认渠道身份且非默认渠道身份必须隔离
- build-release.test.mjs 与 prepare-macos-codex.test.mjs 补齐渠道身份与渠道首装包用例
- 更新 AGC 更新主规范、开发运维文档、共享记忆与渠道安装身份隔离的里程碑与实施计划
2026-09-21 17:06:03 +08:00
kdletters 4951b71d71 项目快照按渠道分区,后台项目工程支持渠道筛选与游标分页
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
2026-09-21 16:57:16 +08:00
lhk229 8520b50c81 修复游戏智能体输入含空白片段时被误判为空 (#449)
Project CI / AI game creator shell Rust smoke (push) Successful in 2m20s
Project CI / AI game creator shell Rust crates (push) Successful in 1m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m51s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 5m21s
Project CI / Backend tests (push) Successful in 7m33s
Project CI / Frontend 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
改为按整条消息判断有效内容,保留空白文本片段及两类引用的原有校验
补充多行文本、纯引用、全空白输入及非法引用的定向回归测试
同步素材引用文档、共享排障记录和过时测试注释

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/449
2026-09-21 16:36:59 +08:00
kdletters c5afc02e94 模板正文目录门禁:CLI 打包与后台上传统一拒绝身份/版本库/依赖/构建目录
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (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 crates (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 / AI game creator shell Rust smoke (push) Has been cancelled
- scripts/agc-template-library-publish.mjs:readProjectFiles 增加正文门禁,任意层级拒绝 .agent/.git/.svn/node_modules,根目录拒绝 dist/build/library/temp/local/.idea/.vscode
- scripts/agc-template-library-publish.test.mjs:新增夹具源目录与门禁用例,覆盖 13 条拒绝路径及 game/dist/**、game/.gitignore 放行
- server-rs/crates/api-server/src/admin_templates.rs:上传归档校验与 CLI 用同一份段名单,新增 template_import_archive_rejects_identity_and_build_directories
- docs/【模板规范】AGC模板包组织指南-2026-09-21.md:已知边界改为「已有机器门禁」,并说明同名目录段只在根目录受限
- docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md:CLI 与后台上传的校验口径补门禁,并追加 2026-09-21 证据(九个模板本地重打包摘要与线上清单逐条一致)
- docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md:验收标准 2 补充门禁证据
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-21 正文目录门禁决策记录
2026-09-21 16:25:35 +08:00
kdletters b5e1c0f1d6 新增模板包组织规范并接入文档索引
Project CI / AI game creator shell Rust crates (push) Successful in 2m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 8m59s
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 / AI game creator shell web tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- 新增 docs/【模板规范】AGC模板包组织指南-2026-09-21.md:ZIP 根目录契约、html 与 Cocos 工程的建议结构与保留项、禁止放入的内容(.agent/.git/node_modules/dist/密钥/嵌套锁文件)、路径与体积上限、封面要求、元数据字段约束、版本不可变规则与发布前自检清单
- 说明两条发布路径的输入差异(CLI 源目录 vs 后台成品 ZIP 上传)与各自上限,并标注「工具不会自动拦截 .agent/.git」这一已知边界
- 主规范与模板库 README(会发布到 OSS templates/README.md)各加一条指向该指南的入口
- docs/README.md 登记新文档,check:doc-index 通过(194 份)
2026-09-21 16:16:21 +08:00
kdletters 15e7d6065e 记录后台模板上传里程碑的实现与证据
Project CI / AI game creator shell Rust crates (push) Successful in 2m58s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / Backend 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 / AI game creator shell Rust lane 2/2 (push) Has been cancelled
- 里程碑 Status 更新为 implemented-awaiting-runtime-acceptance
- 补实现与证据表:路由/权限、写入前整批拒绝、批量原子性、版本一致性、CAS 与锁、保留语义、内容寻址与回读、界面用例与静态检查
- 明确仍未执行:真实 dev bucket 的批量写入 smoke(需用户确认,建议两个 smoke-import 模板验证后下架)
2026-09-21 16:10:55 +08:00
kdletters 2e23a54211 后台模板上传(页面):模板管理页支持批量选择 ZIP 与封面并整批提交
Project CI / AI game creator shell Rust crates (push) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m5s
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
- 新增上传模型:文件名派生模板 ID、按同名匹配封面、逐行字段校验与 multipart 组装(标签去重与上限)
- 模板管理页新增「上传模板」入口与弹窗:多选 ZIP、按 ID 同名多选封面、逐行编辑 ID/名称/版本/运行时/entry、逐行错误、沿用写入确认与防重复提交
- 失败只在弹窗内交代并区分 409 刷新引导与 503 锁占用提示,401 仍交由会话处理;成功后用服务端快照刷新列表并展示逐条导入结果
- admin API client 支持 multipart 请求(不预设 JSON Content-Type),新增 importAdminAgcTemplates 与对应类型
- 主规范新增「后台模板上传」章节并收口原「不上传 ZIP」表述;决策记录补一条
- 验证:admin-web typecheck、页面/模型/客户端 40 项 vitest、check:doc-index、check:encoding、cargo fmt --check、git diff --check
2026-09-21 16:06:23 +08:00
kdletters 4863c8c066 后台模板上传(后端):新增批量导入接口与存储/领域支持
- shared-contracts 新增导入 manifest、逐条与结果 DTO(camelCase,拒绝未知字段)
- module-assets 新增 prepare_template_import:新 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段;同一 ID 同版本字节不同时按 CLI 同一句文案拒绝,字节一致时按内容复用既有对象
- platform-oss 为 application/zip 放宽单对象上限到 64 MiB(图片与元数据仍 5 MiB),测试替身同步该口径
- api-server 新增 POST /admin/api/agc-templates/import:multipart(manifest + zip_N/cover_N),逐项校验归档安全性、entry 存在性与封面(按字节嗅探格式,仅 PNG/JPEG/WebP)后才取发布锁
- 锁内流程:CAS 校验 revision → 内容寻址写入 zip/封面/元数据并回读 → 一次提交清单 → 释放锁;断连由独立任务持有,不确定结果保留锁并返回 503
- 路由挂 require_admin_auth 与 200 MiB body 上限,页签权限矩阵与路由契约测试同步
- 新增用例:清单与文件字段校验、归档越界与符号链接拒绝、内容寻址键与封面嗅探、zip 内容类型存储上限
- 新增「后台模板上传」里程碑与实施计划文档
- 验证:cargo test(module-assets 11 项、platform-oss 12 项、api-server 定向 4 项)、cargo fmt --check、check:doc-index、check:encoding、git diff --check
2026-09-21 16:06:23 +08:00
kdletters 33036b65fc 修复 AGC Windows 构建的 Tauri NSIS 工具链重复下载与截断失败
Project CI / AI game creator shell Rust crates (push) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m16s
Project CI / Backend 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 / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
tauri-bundler 打包时现场从 GitHub 下载 nsis-3.11.zip 与 nsis_tauri_utils.dll 且不重试,
Checkout 的 git clean -fdx 又会删掉工作区内的 target/.tauri,于是每个构建都要重下,
响应一旦被截断只会抛 `io: unexpected end of file`,让发布在 Rust 编译数分钟后失败。

- Jenkins Checkout 的 git clean 增加 -e apps/ai-game-creator-shell/src-tauri/target/.tauri,只保留工具缓存
- 新增 nsis-toolset.mjs:按固定 SHA1 预置 target/.tauri/NSIS,带 4 次重试并把原始归档缓存到工作区外
- 新增 ensure-nsis-toolset.mjs 与 nsis:prepare 脚本,作为 Jenkins 预检入口
- buildRelease 在 Windows 目标打包前接入工具链预置,预置失败即失败关闭
- Jenkins 新增 Tauri NSIS toolchain 阶段,在编译前预置、跑回归测试并校验 makensis 可执行
- 新增 nsis-toolset.test.mjs 并补 3 项 build-release 预置接入测试
- 同步开发运维文档与 shared-memory 排障记录
2026-09-21 15:59:30 +08:00
kdletters f4ebc45612 模板库线上对齐仓库源:递增版本重发并把说明文档纳入发布
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 4m48s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m38s
Project CI / Frontend tests (push) Successful in 10m0s
Project CI / Native shell tests (push) Successful in 11m37s
Project CI / Repository checks (push) Successful in 8m49s
Project CI / Backend tests (push) Successful in 12m24s
Project CI / AI game creator shell web tests (push) Successful in 4m50s
- 7 个内容已变的模板 templateVersion 升到 0.1.1,blank-2d-canvas 与 blank-web 保持 0.1.0
- 发布脚本把仓库 README.md 覆盖写到 templates/README.md(上限 64 KiB,写在清单之前并回读校验),线上说明文档不再是手工副本
- 修复守卫拒绝时直接 process.exit(1) 触发 libuv 断言崩溃、看不到拒绝原因的问题
- 新增发布用例:说明文档随发布覆盖写且写在清单之前、源目录没有 README 时不写线上文档
- 模板库新增仓库侧 README.md 源文件(发布后与线上逐字节一致)
- 技术方案补说明文档契约与 2026-09-21 重发布记录;决策记录与踩坑记录各补一条
- 验证:node --test 27 项、真实发布 28 个对象回读通过、线上清单与 README 核对一致、AGC 壳 3 项线上用例重跑通过、check:doc-index、check:encoding、eslint、git diff --check
2026-09-21 14:51:39 +08:00
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 19ed1776f1 收口 AGC 模板库客户端接入里程碑:补缓存回退与建项失败清理证据
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
- 客户端清单来源抽成可测判定:远端合法正文优先并标 network,远端失败回退本机缓存并标 cache,无缓存时暴露远端错误
- 远端已答话但正文非法(不是合法 UTF-8 或不符合 schema)与缓存自身损坏时一律失败关闭,不用缓存掩盖远端问题
- 补 5 项清单来源判定用例
- 大小 / 摘要不一致与越界归档用例补断言:拒绝后不留安装目录、不产生已下载判据
- 新增建项失败清理用例:复制阶段失败时删除刚创建的项目目录,不留半成品
- 主规范并入清单来源与安装判据口径,并写入 2026-09-21 复核证据
- 里程碑置为 accepted 并补 7 条验收项证据表,按工作流删除对应实施计划
- 验证:cargo test template_library 24 项与 --ignored 线上 3 项、前端 4 个模板用例文件 38 项、appSurface 首页模板 2 项、AGC typecheck、check:encoding、check:doc-index、cargo fmt --check、git diff --check
2026-09-21 14:40:15 +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
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
221 changed files with 9245 additions and 2537 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` is a legacy value resolved only when submitting a new task; the retired `gpt-image-2-c` is no longer accepted and is handled as an unsupported value.
- 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`.
+22 -14
View File
@@ -1,11 +1,17 @@
---
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 Tiantoken GPT Image 2.5 workflow. 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 TIANTOKEN_BASE_URL / TIANTOKEN_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. Provider routing is owned by `server-rs`; when this skill talks to the provider directly it must send the concrete provider model and the matching provider credentials, because the provider side only accepts concrete models:
- generation (no reference images): `gpt-image-2.5-flare-c` through Tiantoken
- edits (any reference image): `gpt-image-2.5-sunburst-c` through Tiantoken
- nanobanana (`gemini-3.1-flash-image-preview`) stays on VectorEngine
The business model name `gpt-image-2.5` is resolved to a concrete key by `server-rs` at the task boundary and is never sent to a provider directly. 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
@@ -24,15 +30,15 @@ Use this skill for project-local image asset generation that must match the repo
```
5. Save final project assets under `public/` or another explicitly requested workspace path.
6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists.
6. Never print `TIANTOKEN_API_KEY`. Report only whether configuration exists.
## Request Contract
The repository image path uses:
```text
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
POST {TIANTOKEN_BASE_URL}/v1/images/generations
Authorization: Bearer {TIANTOKEN_API_KEY}
Content-Type: application/json
```
@@ -40,7 +46,7 @@ Default body:
```json
{
"model": "gpt-image-2",
"model": "gpt-image-2.5-flare-c",
"prompt": "<prompt>",
"n": 1,
"size": "1024x1024"
@@ -50,22 +56,22 @@ Default body:
For visual references, use the edit endpoint instead of the create endpoint:
```text
POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
POST {TIANTOKEN_BASE_URL}/v1/images/edits
Authorization: Bearer {TIANTOKEN_API_KEY}
Content-Type: multipart/form-data
```
Multipart fields:
```text
model=gpt-image-2
model=gpt-image-2.5-sunburst-c
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` with `gpt-image-2.5-flare-c`; calls with any reference image use `POST /v1/images/edits` with `gpt-image-2.5-sunburst-c` and pass references as one or more `image` form parts. Server-side calls still submit the business model `gpt-image-2.5` and let `server-rs` resolve the concrete key; provider routing and retry policy remain server-owned, and pricing follows the same reference-image rule (generation with a reference image is charged at the edit tier). 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.
@@ -75,12 +81,14 @@ Load environment values from process env first, then `.env.secrets.local`, `.env
Required for live generation:
- `VECTOR_ENGINE_BASE_URL`
- `VECTOR_ENGINE_API_KEY`
- `TIANTOKEN_BASE_URL`
- `TIANTOKEN_API_KEY`
Optional:
- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS`
`VECTOR_ENGINE_*` values no longer serve GPT Image 2.5: those credentials belong to VectorEngine, which only serves nanobanana. Do not fall back from `TIANTOKEN_*` to `VECTOR_ENGINE_*`.
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
@@ -9,8 +9,9 @@ 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';
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
const preferredImageModel = 'gpt-image-2.5-flare-c';
const prompts = [
{
@@ -101,18 +102,18 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
function buildTiantokenImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
@@ -233,22 +234,22 @@ async function fetchJson(url, options, timeoutMs) {
const text = await response.text();
if (!response.ok) {
const error = new Error(
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
);
error.vectorEngineStatus = response.status;
error.vectorEngineBody = text;
error.providerStatus = response.status;
error.providerBody = text;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
error.providerResponseParse = true;
error.providerBody = text;
throw error;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -256,81 +257,35 @@ 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]) {
const requestBody = {
model,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
};
try {
const payload = await fetchJson(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(
extractBase64Images(payload)[0],
);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
error.vectorEngineResponseParse = true;
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}`,
);
}
const model = preferredImageModel;
const requestBody = {
model,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
};
const payload = await fetchJson(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${entry.id}`);
const error = new Error(`Tiantoken returned no image for ${entry.id}`);
error.providerResponseParse = true;
error.providerBody = JSON.stringify(payload).slice(0, 600);
throw error;
}
async function downloadUrl(url, timeoutMs) {
@@ -373,7 +328,7 @@ async function generateOne(env, entry, outDir) {
const bytes = decodeStrictBase64Image(b64Images[0]);
if (!bytes) {
throw new Error(
`VectorEngine returned invalid base64 image for ${entry.id}`,
`Tiantoken returned invalid base64 image for ${entry.id}`,
);
}
image = {
@@ -381,7 +336,7 @@ async function generateOne(env, entry, outDir) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${entry.id}`);
throw new Error(`Tiantoken returned no image for ${entry.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -408,7 +363,6 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({
id: entry.id,
title: entry.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(entry),
@@ -429,7 +383,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -18,8 +18,9 @@ const defaultOutDir = path.join(
'puzzle-creation-templates',
);
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
const preferredImageModel = 'gpt-image-2.5-flare-c';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
@@ -71,18 +72,18 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
function buildTiantokenImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
@@ -203,22 +204,22 @@ async function fetchJson(url, options, timeoutMs) {
const text = await response.text();
if (!response.ok) {
const error = new Error(
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
);
error.vectorEngineStatus = response.status;
error.vectorEngineBody = text;
error.providerStatus = response.status;
error.providerBody = text;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
error.providerResponseParse = true;
error.providerBody = text;
throw error;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -226,83 +227,35 @@ 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]) {
const requestBody = {
model,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
};
try {
const payload = await fetchJson(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(
extractBase64Images(payload)[0],
);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
const error = new Error(
`VectorEngine returned no image for ${template.id}`,
);
error.vectorEngineResponseParse = true;
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}`,
);
}
const model = preferredImageModel;
const requestBody = {
model,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
};
const payload = await fetchJson(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${template.id}`);
const error = new Error(`Tiantoken returned no image for ${template.id}`);
error.providerResponseParse = true;
error.providerBody = JSON.stringify(payload).slice(0, 600);
throw error;
}
async function downloadUrl(url, timeoutMs) {
@@ -345,7 +298,7 @@ async function generateOne(env, template, outDir) {
const bytes = decodeStrictBase64Image(b64Images[0]);
if (!bytes) {
throw new Error(
`VectorEngine returned invalid base64 image for ${template.id}`,
`Tiantoken returned invalid base64 image for ${template.id}`,
);
}
image = {
@@ -353,7 +306,7 @@ async function generateOne(env, template, outDir) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${template.id}`);
throw new Error(`Tiantoken returned no image for ${template.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -384,7 +337,6 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(template),
@@ -405,7 +357,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
+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.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。已退役的 `gpt-image-2-c` 已从代码整体删除,只有数据库里的历史审计字符串原样保留,任何入口传入该值都按不支持的值处理。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端、为已删除的 `gpt-image-2-c` 重新加回常量或解析分支
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -6,6 +6,7 @@ import {
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
@@ -65,6 +66,50 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+34 -1
View File
@@ -30,9 +30,11 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
@@ -87,6 +89,8 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -174,6 +178,8 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -209,6 +215,7 @@ export function listAdminProjectSnapshots(
) {
const params = new URLSearchParams();
if (query.cursor) params.set('cursor', query.cursor);
if (query.channel) params.set('channel', query.channel);
params.set('limit', String(query.limit ?? 20));
return request<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
@@ -216,13 +223,27 @@ export function listAdminProjectSnapshots(
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/admin/api/project-snapshots/channels',
{ token, signal },
);
}
export async function downloadAdminProjectSnapshot(
token: string,
channel: string,
userId: string,
projectId: string,
signal?: AbortSignal,
) {
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
const params = new URLSearchParams();
if (channel) params.set('channel', channel);
const query = params.toString();
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
const response = await fetch(buildRequestUrl(path), {
headers: {
Authorization: `Bearer ${token.trim()}`,
@@ -1196,3 +1217,15 @@ export function updateAdminAgcTemplate(
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
+41
View File
@@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry {
fileCount: number;
totalBytes: number;
status: 'ready' | 'partial' | 'unverified';
channel: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
}
export interface AdminProjectSnapshotListQuery {
cursor?: string | null;
limit?: number;
channel?: string | null;
}
export interface AdminProjectSnapshotListResponse {
@@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse {
nextCursor: string | null;
}
export interface AdminProjectSnapshotChannelsResponse {
defaultChannel: string;
channels: string[];
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
@@ -1065,3 +1074,35 @@ export interface AdminUpdateAgcTemplateRequest {
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from './adminApiClient';
@@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
expect(
await listAdminProjectSnapshots(
'admin-token',
{ cursor: 'user/a+项目', limit: 20 },
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
controller.signal,
),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
signal: controller.signal,
@@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
);
});
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/channels',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
);
});
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('PK\u0003\u0004', {
@@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
const controller = new AbortController();
const archive = await downloadAdminProjectSnapshot(
'admin-token',
'release',
'user/a',
'project/b',
controller.signal,
@@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
expect(archive.filename).toBe('三消-r2.zip');
expect(archive.blob.type).toBe('application/zip');
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
@@ -81,7 +100,8 @@ test.each([
vi.fn().mockResolvedValue(new Response('PK', { headers })),
);
expect(
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
.filename,
).toBe(expected.replace('工程', 'project'));
});
@@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])(
),
);
await expect(
downloadAdminProjectSnapshot('token', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({
status,
code: 'SNAPSHOT_FAILURE',
@@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
),
);
await expect(
downloadAdminProjectSnapshot('token', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
});
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
getAdminAgcTemplates,
importAdminAgcTemplates,
updateAdminAgcTemplate,
} from '../api/adminApiClient';
import type {
@@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
vi.mock('../api/adminApiClient', async (importOriginal) => ({
...(await importOriginal<typeof import('../api/adminApiClient')>()),
getAdminAgcTemplates: vi.fn(),
importAdminAgcTemplates: vi.fn(),
updateAdminAgcTemplate: vi.fn(),
}));
@@ -79,6 +81,21 @@ beforeEach(() => {
: entry,
),
}));
vi.mocked(importAdminAgcTemplates)
.mockReset()
.mockImplementation(async () => ({
...structuredClone(library),
revision: 'rev-imported',
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
}));
vi.stubGlobal(
'URL',
Object.assign(class extends URL {}, {
@@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库'
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
function uploadZipFile(name: string) {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
function uploadCoverFile(name: string) {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
type: 'image/png',
});
}
async function openUploadDialog() {
fireEvent.click(await screen.findByRole('button', { name: '上传模板' }));
return screen.getByRole('dialog', { name: '上传模板' });
}
function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
const input = dialog.querySelector<HTMLInputElement>(
`input[accept="${accept}"]`,
);
if (!input) throw new Error(`missing file input for ${accept}`);
fireEvent.change(input, { target: { files } });
}
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
pickUploadFiles(dialog, '.zip,application/zip', [
uploadZipFile('alpha.zip'),
uploadZipFile('beta.zip'),
]);
expect(within(dialog).getByText('alpha.zip')).not.toBeNull();
expect(within(dialog).getByText('beta.zip')).not.toBeNull();
expect(
within(dialog)
.getByRole('button', { name: /上传 2 个模板/ })
.hasAttribute('disabled'),
).toBe(true);
expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2);
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
uploadCoverFile('alpha.png'),
uploadCoverFile('beta.png'),
]);
expect(within(dialog).getByText('alpha.png')).not.toBeNull();
expect(
within(dialog)
.getByRole('button', { name: /上传 2 个模板/ })
.hasAttribute('disabled'),
).toBe(false);
});
test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
uploadCoverFile('alpha.png'),
]);
fireEvent.click(
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
);
await confirmWrite();
await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1));
const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1];
const manifest = JSON.parse(String(formData.get('manifest')));
expect(manifest.expectedRevision).toBe('rev-1');
expect(manifest.templates[0]).toMatchObject({
id: 'alpha',
zipField: 'zip_0',
coverField: 'cover_0',
});
expect((formData.get('zip_0') as File).name).toBe('alpha.zip');
expect((formData.get('cover_0') as File).name).toBe('alpha.png');
expect(
(await screen.findAllByText(/已导入 1 个模板/)).length,
).toBeGreaterThan(0);
});
test('批量上传:冲突给出刷新引导', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
uploadCoverFile('alpha.png'),
]);
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({
status: 409,
message: '模板库已更新,请刷新后重新编辑',
}),
);
fireEvent.click(
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
);
await confirmWrite();
expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull();
expect(
within(dialog)
.getByRole('button', { name: /上传 1 个模板/ })
.hasAttribute('disabled'),
).toBe(true);
});
test('批量上传:服务端拒绝时展示真实原因', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
uploadCoverFile('alpha.png'),
]);
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
new AdminApiError({
status: 400,
message: 'alpha:模板包缺少清单声明的 entryindex.html',
}),
);
fireEvent.click(
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
);
await confirmWrite();
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
});
@@ -11,7 +11,7 @@ import {
TableRow,
TextField,
} from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { RefreshCcw, Upload } from 'lucide-react';
import {
type FormEvent,
useCallback,
@@ -21,16 +21,28 @@ import {
} from 'react';
import {
formatAdminApiError,
getAdminAgcTemplates,
importAdminAgcTemplates,
isAdminApiError,
updateAdminAgcTemplate,
} from '../api/adminApiClient';
import type {
AdminAgcTemplateLibraryResponse,
AdminAgcTemplatePayload,
AdminImportAgcTemplateResult,
AdminUpdateAgcTemplateRequest,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import {
attachCoverFiles,
buildTemplateImportFormData,
deriveTemplateUploadRow,
TEMPLATE_IMPORT_MAX_BATCH,
TEMPLATE_UPLOAD_RUNTIMES,
type TemplateUploadRow,
validateTemplateUploadRows,
} from './adminAgcTemplateUploadModel';
import { handlePageError, splitLines } from './pageUtils';
type PageProps = { token: string; onUnauthorized: (message?: string) => void };
@@ -39,6 +51,14 @@ type EditingTemplate = {
revision: string;
key: number;
};
type TemplateUploadState = {
rows: TemplateUploadRow[];
errors: Record<string, string>;
submitting: boolean;
error: string;
results: AdminImportAgcTemplateResult[] | null;
};
const runtimeLabels: Record<string, string> = {
html: 'HTML',
cocos: 'Cocos',
@@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
const [notice, setNotice] = useState('');
const [conflict, setConflict] = useState(false);
const [editing, setEditing] = useState<EditingTemplate | null>(null);
const [upload, setUpload] = useState<TemplateUploadState | null>(null);
const mounted = useRef(false);
const readGeneration = useRef(0);
const readController = useRef<AbortController | null>(null);
@@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
setEditing(null);
}
function openUpload() {
if (writesDisabled || !snapshot) return;
setUpload({
rows: [],
errors: {},
submitting: false,
error: '',
results: null,
});
}
function closeUpload() {
if (upload?.submitting) return;
setUpload(null);
}
async function submitUpload(rows: TemplateUploadRow[]) {
const revision = snapshot?.revision;
if (
!revision ||
writing.current ||
loading ||
conflict ||
!snapshot?.writable
) {
return;
}
const errors = validateTemplateUploadRows(rows);
if (Object.keys(errors).length > 0) {
setUpload((current) => (current ? { ...current, errors } : current));
return;
}
writing.current = true;
setUpload((current) =>
current
? { ...current, submitting: true, errors: {}, error: '', results: null }
: current,
);
setError('');
setNotice('');
try {
const confirmed = await confirmWrite({
action: '上传模板',
target: `${rows.length} 个模板(发布后不可删除,只能下架)`,
});
if (!confirmed || !mounted.current) return;
const response = await importAdminAgcTemplates(
token,
buildTemplateImportFormData(revision, rows),
);
if (!mounted.current) return;
readGeneration.current += 1;
readController.current?.abort();
setSnapshot({
revision: response.revision,
writable: response.writable,
templates: response.templates,
});
setConflict(false);
setNotice(`已导入 ${response.imported.length} 个模板`);
setUpload((current) =>
current
? {
...current,
submitting: false,
rows: [],
results: response.imported,
}
: current,
);
} catch (error) {
if (!mounted.current) return;
const conflictNow = isAdminApiError(error) && error.status === 409;
setConflict(conflictNow);
const message = formatUploadError(error);
setUpload((current) =>
current ? { ...current, submitting: false, error: message } : current,
);
// 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。
if (isAdminApiError(error) && error.status === 401) {
handlePageError(error, unauthorized.current, setError);
}
} finally {
writing.current = false;
setUpload((current) =>
current ? { ...current, submitting: false } : current,
);
}
}
const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
const entries = (snapshot?.templates ?? []).filter((entry) => {
const searchable = [entry.id, entry.title, ...entry.tags]
@@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
<div className="admin-page-heading">
<h2></h2>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
</Button>
<div className="admin-actions">
<Button
variant="secondary"
disabled={writesDisabled || !snapshot}
onClick={openUpload}
>
<Upload size={16} aria-hidden="true" />
</Button>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
</Button>
</div>
</div>
{readOnly ? (
<Status tone="warning"></Status>
@@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
) : (
confirmDialog
)}
{upload ? (
<Modal
open
title="上传模板"
description={`一批最多 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板;发布后不可删除,只能下架`}
closeLabel="关闭模板上传"
onClose={closeUpload}
closeOnEscape={!upload.submitting}
closeOnBackdrop={!upload.submitting}
className="admin-agc-template-upload-dialog genarrative-ui"
>
<TemplateUploadDialog
state={upload}
disabled={writesDisabled}
readOnly={readOnly}
busy={busy}
conflict={conflict}
onRowsChange={(rows) =>
setUpload((current) =>
current
? {
...current,
rows,
errors: validateTemplateUploadRows(rows),
}
: current,
)
}
onSubmit={() => void submitUpload(upload.rows)}
onClose={closeUpload}
onRefresh={() => void refresh()}
/>
{confirmDialog}
</Modal>
) : null}
</section>
);
}
@@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function formatUploadError(error: unknown) {
const message = formatAdminApiError(error);
if (isAdminApiError(error) && error.status === 409) {
return `${message}(模板库已更新,请刷新列表后重试)`;
}
if (isAdminApiError(error) && error.status === 503) {
return `${message}(发布锁可能仍被占用,请联系运维核对)`;
}
return message;
}
function TemplateUploadDialog({
state,
disabled,
readOnly,
busy,
conflict,
onRowsChange,
onSubmit,
onClose,
onRefresh,
}: {
state: TemplateUploadState;
disabled: boolean;
readOnly: boolean;
busy: boolean;
conflict: boolean;
onRowsChange: (rows: TemplateUploadRow[]) => void;
onSubmit: () => void;
onClose: () => void;
onRefresh: () => void;
}) {
const uploadBusy = state.submitting;
const rowErrorCount = Object.keys(state.errors).length;
function appendZipFiles(files: File[]) {
const known = new Set(state.rows.map((row) => row.key));
const next = [...state.rows];
for (const file of files) {
if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue;
known.add(file.name);
next.push(deriveTemplateUploadRow(file));
}
onRowsChange(next);
}
function updateRow(key: string, patch: Partial<TemplateUploadRow>) {
onRowsChange(
state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
);
}
return (
<>
{readOnly ? (
<Status tone="warning"></Status>
) : null}
<div className="admin-agc-template-upload-pickers">
<label className="admin-agc-template-upload-picker">
<span> .zip</span>
<input
type="file"
accept=".zip,application/zip"
multiple
disabled={disabled || uploadBusy}
onChange={(event) => {
appendZipFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = '';
}}
/>
</label>
<label className="admin-agc-template-upload-picker">
<span> ID PNG / JPEG / WebP</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
disabled={disabled || uploadBusy}
onChange={(event) => {
onRowsChange(
attachCoverFiles(state.rows, [
...(event.currentTarget.files ?? []),
]),
);
event.currentTarget.value = '';
}}
/>
</label>
</div>
{state.rows.length === 0 ? (
<Status tone="info">
</Status>
) : (
<div className="admin-agc-template-upload-scroll">
<Table>
<TableHead>
<TableRow>
<TableHeader></TableHeader>
<TableHeader>ID</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
<TableHeader>entry</TableHeader>
<TableHeader></TableHeader>
<TableHeader></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{state.rows.map((row) => {
const rowError = state.errors[row.key];
const rowDisabled = disabled || uploadBusy;
return (
<TableRow key={row.key}>
<TableCell>
<div className="admin-agc-template-upload-file">
{row.zipFile.name}
</div>
{rowError ? (
<div
className="admin-agc-template-upload-row-error"
role="alert"
>
{rowError}
</div>
) : null}
</TableCell>
<TableCell>
<TextField
label="ID"
value={row.id}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, { id: event.currentTarget.value })
}
/>
</TableCell>
<TableCell>
<TextField
label="名称"
value={row.title}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
title: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
<TextField
label="版本"
value={row.templateVersion}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
templateVersion: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
<SelectField
label="运行时"
value={row.runtime}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
runtime: event.currentTarget.value,
})
}
>
{TEMPLATE_UPLOAD_RUNTIMES.map((value) => (
<option key={value} value={value}>
{runtimeLabels[value] ?? value}
</option>
))}
</SelectField>
</TableCell>
<TableCell>
<TextField
label="entry"
value={row.entry}
disabled={rowDisabled}
onChange={(event) =>
updateRow(row.key, {
entry: event.currentTarget.value,
})
}
/>
</TableCell>
<TableCell>
{row.coverFile ? (
<span className="admin-agc-template-upload-file">
{row.coverFile.name}
</span>
) : (
<Status tone="warning"></Status>
)}
</TableCell>
<TableCell>
<Button
variant="secondary"
disabled={rowDisabled}
onClick={() =>
onRowsChange(
state.rows.filter((entry) => entry.key !== row.key),
)
}
>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{state.error ? (
<Status tone="error" role="alert">
{state.error}
</Status>
) : null}
{conflict ? (
<Button variant="secondary" disabled={busy} onClick={onRefresh}>
</Button>
) : null}
{state.results ? (
<Status tone="success">
{`已导入 ${state.results.length} 个模板:${state.results
.map((result) => `${result.id}@${result.templateVersion}`)
.join('、')}`}
</Status>
) : null}
<div className="admin-agc-template-dialog-actions">
<Button
type="button"
variant="secondary"
disabled={uploadBusy}
onClick={onClose}
>
</Button>
<Button
type="button"
disabled={
disabled ||
uploadBusy ||
state.rows.length === 0 ||
rowErrorCount > 0
}
onClick={onSubmit}
>
{uploadBusy ? '上传中…' : `上传 ${state.rows.length} 个模板`}
</Button>
</div>
</>
);
}
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
@@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({
'../api/adminApiClient',
)),
downloadAdminProjectSnapshot: vi.fn(),
getAdminProjectSnapshotChannels: vi.fn(),
listAdminProjectSnapshots: vi.fn(),
}));
@@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = {
fileCount: 12,
totalBytes: 2048,
status: 'ready',
channel: 'dev',
authorDisplayName: '陶泥作者',
authorPublicUserCode: 'SY-00000007',
};
beforeEach(() => {
vi.mocked(listAdminProjectSnapshots)
.mockReset()
.mockResolvedValue({ items: [entry], nextCursor: null });
vi.mocked(getAdminProjectSnapshotChannels)
.mockReset()
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
vi.mocked(downloadAdminProjectSnapshot).mockReset();
});
afterEach(() => {
@@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
).toBe(false);
});
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
test('按游标翻页回到上一页时复用已取得的游标', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
const pagination = await screen.findByRole('navigation', {
name: '项目工程分页',
});
expect(pagination.textContent).toContain('第 1 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
2,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.queryByText('三消工程')).toBeNull();
expect(pagination.textContent).toContain('第 2 页');
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(pagination.textContent).toContain('第 1 页');
});
test('切换每页条数从第一页按新条数重新加载', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
// 重新加载时页脚会重建,必须重新取节点再断言。
expect(
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
).toContain('第 1 页');
});
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: null,
})
.mockRejectedValueOnce(new Error('远端清单读取失败'))
.mockResolvedValueOnce({ items: [], nextCursor: null });
.mockRejectedValueOnce(new Error('翻页读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
2,
'token',
{ cursor: 'page-2', limit: 20 },
expect.any(AbortSignal),
);
expect(screen.getByText('三消工程')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByRole('alert');
expect(screen.getByText('第二工程')).toBeTruthy();
expect(screen.queryByText('暂无已上传项目')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('暂无已上传项目');
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20 },
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
expect(screen.getByText('第二工程')).toBeTruthy();
expect(screen.queryByText('暂无已上传项目')).toBeNull();
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('暂无已上传项目');
expect(screen.queryByRole('alert')).toBeNull();
});
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const row = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(row).getByText('陶泥作者')).toBeTruthy();
expect(within(row).getByText('SY-00000007')).toBeTruthy();
expect(
within(row).getByRole('button', { name: '查看用户信息' }),
).toBeTruthy();
expect(within(row).queryByText('user-1')).toBeNull();
});
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'release',
channels: ['dev', 'release'],
});
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'release' },
expect.any(AbortSignal),
);
const channelSelect = screen.getByLabelText('项目工程渠道');
expect((channelSelect as HTMLSelectElement).value).toBe('release');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第 2 页,本页 1 个项目');
fireEvent.change(channelSelect, { target: { value: 'dev' } });
await screen.findByText('dev 工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
});
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
@@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
'token',
'dev',
'user-1',
'project-1',
expect.any(AbortSignal),
@@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
(_token, _user, _project, signal) =>
(_token, _channel, _user, _project, signal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
@@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
expect(
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
).toBe(true);
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
@@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
onUnauthorized={onUnauthorized}
/>,
);
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
await waitFor(() =>
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
);
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
view.rerender(
<AdminProjectSnapshotsPage
@@ -278,7 +421,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
view.unmount();
expect(signal?.aborted).toBe(true);
await act(async () => {
@@ -1,11 +1,19 @@
import { Download, RefreshCcw, X } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
Download,
RefreshCcw,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminProjectSnapshotsPageProps {
@@ -31,21 +39,32 @@ const snapshotStatuses = {
},
};
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
export function AdminProjectSnapshotsPage({
token,
onUnauthorized,
}: AdminProjectSnapshotsPageProps) {
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
const [channel, setChannel] = useState<string | null>(null);
const [channelOptions, setChannelOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const listController = useRef<AbortController | null>(null);
const downloadController = useRef<AbortController | null>(null);
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
const loadPage = useCallback(
async (cursor: string | null = null) => {
async (cursor: string | null, limit: number, page: number) => {
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
@@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({
try {
const response = await listAdminProjectSnapshots(
token,
{ cursor, limit: 20 },
{ cursor, limit, channel },
controller.signal,
);
if (controller.signal.aborted) return;
setItems((current) => {
if (!cursor) return response.items;
const entries = new Map(
current.map((entry) => [snapshotKey(entry), entry]),
);
response.items.forEach((entry) =>
entries.set(snapshotKey(entry), entry),
);
return [...entries.values()];
});
setItems(response.items);
setNextCursor(response.nextCursor);
setPageIndex(page);
setHasLoaded(true);
} catch (error: unknown) {
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
if (!controller.signal.aborted)
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({
}
}
},
[token, onUnauthorized],
[token, onUnauthorized, channel],
);
useEffect(() => {
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
const controller = new AbortController();
void (async () => {
try {
const response = await getAdminProjectSnapshotChannels(
token,
controller.signal,
);
if (controller.signal.aborted) return;
setChannelOptions(response.channels);
setChannel((current) => current ?? response.defaultChannel);
} catch (error: unknown) {
if (controller.signal.aborted) return;
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
handlePageError(error, onUnauthorized, setErrorMessage);
setChannel((current) => current ?? '');
}
})();
return () => controller.abort();
}, [token, onUnauthorized]);
useEffect(() => {
if (channel === null) return undefined;
pageCursors.current = [null];
setItems([]);
setNextCursor(null);
setPageIndex(1);
setHasLoaded(false);
setDownloadingKey(null);
void loadPage();
void loadPage(null, pageSize, 1);
return () => {
listController.current?.abort();
listController.current = null;
downloadController.current?.abort();
downloadController.current = null;
};
}, [loadPage]);
}, [loadPage, pageSize, channel]);
function goToNextPage() {
if (!nextCursor) return;
pageCursors.current[pageIndex] = nextCursor;
void loadPage(nextCursor, pageSize, pageIndex + 1);
}
function goToPreviousPage() {
if (pageIndex <= 1) return;
void loadPage(
pageCursors.current[pageIndex - 2] ?? null,
pageSize,
pageIndex - 1,
);
}
function refreshCurrentPage() {
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
pageIndex,
);
}
async function downloadProject(entry: AdminProjectSnapshotEntry) {
if (downloadController.current || entry.status === 'partial') return;
@@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({
try {
const archive = await downloadAdminProjectSnapshot(
token,
channel ?? '',
entry.userId,
entry.projectId,
controller.signal,
@@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({
setDownloadingKey(null);
}
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
const visibleChannelOptions = channelOptions.length
? channelOptions
: channel
? [channel]
: [];
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2></h2>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadPage()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="alert">
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({
<thead>
<tr>
<th></th>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户 ID">{entry.userId}</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
@@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({
{hasLoaded && items.length === 0 && !errorMessage ? (
<p className="admin-muted-text"></p>
) : null}
{nextCursor ? (
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadPage(nextCursor)}
>
{isLoading ? '加载中' : '加载更多'}
</button>
</div>
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
{pageIndex}
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span></span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span></span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
</section>
);
}
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
attachCoverFiles,
buildTemplateImportFormData,
deriveTemplateUploadRow,
parseTemplateTags,
sanitizeTemplateId,
TEMPLATE_IMPORT_MAX_BATCH,
type TemplateUploadRow,
validateTemplateUploadRows,
} from './adminAgcTemplateUploadModel';
function zipFile(name: string) {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
function coverFile(name: string) {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
type: 'image/png',
});
}
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
}
describe('sanitizeTemplateId', () => {
it('lowercases, keeps whitelisted characters and drops traversal', () => {
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
});
});
describe('deriveTemplateUploadRow', () => {
it('starts from safe defaults so a batch only needs covers attached', () => {
const derived = row('my-template.zip');
expect(derived.id).toBe('my-template');
expect(derived.title).toBe('my-template');
expect(derived.runtime).toBe('html');
expect(derived.templateVersion).toBe('0.1.0');
expect(derived.entry).toBe('index.html');
expect(derived.coverFile).toBeNull();
});
});
describe('attachCoverFiles', () => {
it('matches covers by template id and ignores non-image files', () => {
const rows = [row('alpha.zip'), row('beta.zip')];
const covers = [
coverFile('alpha.png'),
coverFile('beta.webp'),
coverFile('notes.txt'),
];
const next = attachCoverFiles(rows, covers);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
expect(next[1]?.coverFile?.name).toBe('beta.webp');
});
it('keeps an already matched cover when the new selection has no partner', () => {
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
const next = attachCoverFiles(rows, [coverFile('other.png')]);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
});
});
describe('validateTemplateUploadRows', () => {
it('accepts a complete batch', () => {
const rows = [
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
];
expect(validateTemplateUploadRows(rows)).toEqual({});
});
it('reports missing cover, duplicate id and invalid fields per row', () => {
const rows = [
row('alpha.zip'),
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
row('gamma.zip', {
coverFile: coverFile('gamma.png'),
runtime: 'docker',
templateVersion: 'Bad Version',
entry: '../escape.js',
title: ' ',
}),
];
const errors = validateTemplateUploadRows(rows);
expect(errors['alpha.zip']).toContain('缺少封面');
expect(errors.second).toContain('ID 在本批次内重复');
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
});
it('rejects a batch larger than the server limit', () => {
const rows = Array.from(
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
(_, index) =>
row(`template-${index}.zip`, {
coverFile: coverFile(`template-${index}.png`),
}),
);
const errors = validateTemplateUploadRows(rows);
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
expect(errors['template-0.zip']).toContain('单批最多上传');
});
});
describe('buildTemplateImportFormData', () => {
it('writes manifest field names and attaches zip / cover per row', () => {
const rows = [
row('alpha.zip', {
coverFile: coverFile('alpha.png'),
tags: '起步, 起步 2d',
title: ' Alpha ',
}),
row('beta.zip', { coverFile: coverFile('beta.png') }),
];
const form = buildTemplateImportFormData('a'.repeat(64), rows);
const manifest = JSON.parse(String(form.get('manifest')));
expect(manifest.expectedRevision).toBe('a'.repeat(64));
expect(manifest.templates).toHaveLength(2);
expect(manifest.templates[0]).toMatchObject({
id: 'alpha',
title: 'Alpha',
tags: ['起步', '2d'],
zipField: 'zip_0',
coverField: 'cover_0',
});
expect(manifest.templates[1]).toMatchObject({
id: 'beta',
zipField: 'zip_1',
coverField: 'cover_1',
});
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
expect((form.get('cover_1') as File).name).toBe('beta.png');
});
it('parses tags with dedupe and caps them at the server limit', () => {
expect(parseTemplateTags(' a, ab c ')).toEqual(['a', 'b', 'c']);
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
',',
);
expect(parseTemplateTags(many)).toHaveLength(16);
});
});
@@ -0,0 +1,191 @@
import type {
AdminImportAgcTemplateItemPayload,
AdminImportAgcTemplatesManifest,
} from '../api/adminApiTypes';
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
export const TEMPLATE_UPLOAD_RUNTIMES = [
'html',
'unity',
'godot',
'cocos',
] as const;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
export interface TemplateUploadRow {
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
key: string;
zipFile: File;
coverFile: File | null;
id: string;
title: string;
summary: string;
tags: string;
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
}
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
export function sanitizeTemplateId(value: string) {
return value
.replace(/\.zip$/iu, '')
.trim()
.toLowerCase()
.replace(/\.{2,}/gu, '.')
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/^[^a-z0-9]+/u, '')
.slice(0, 64);
}
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
const id = sanitizeTemplateId(zipFile.name);
return {
key: zipFile.name,
zipFile,
coverFile: null,
id,
title: id,
summary: '',
tags: '',
runtime: 'html',
engine: '',
engineVersion: '',
templateVersion: '0.1.0',
entry: 'index.html',
};
}
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
export function attachCoverFiles(
rows: TemplateUploadRow[],
coverFiles: File[],
): TemplateUploadRow[] {
const covers = new Map<string, File>();
for (const file of coverFiles) {
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
const stem = sanitizeTemplateId(
file.name.replace(COVER_EXTENSION_PATTERN, ''),
);
if (!covers.has(stem)) covers.set(stem, file);
}
return rows.map((row) => {
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
return matched ? { ...row, coverFile: matched } : row;
});
}
export function parseTemplateTags(value: string) {
const seen = new Set<string>();
const tags: string[] = [];
for (const raw of value.split(/[,\s]+/u)) {
const tag = raw.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags.slice(0, 16);
}
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
export function validateTemplateUploadRows(
rows: TemplateUploadRow[],
): Record<string, string> {
const errors: Record<string, string> = {};
const seen = new Set<string>();
const fail = (row: TemplateUploadRow, message: string) => {
if (!errors[row.key]) errors[row.key] = message;
};
if (rows.length === 0) return errors;
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
for (const row of rows) {
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
}
return errors;
}
for (const row of rows) {
const id = row.id.trim();
if (!TEMPLATE_ID_PATTERN.test(id)) {
fail(
row,
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
);
} else if (seen.has(id)) {
fail(row, 'ID 在本批次内重复');
} else {
seen.add(id);
}
if (!row.title.trim() || row.title.trim().length > 80) {
fail(row, '名称必须是 1-80 个字符');
}
if (row.summary.trim().length > 1000) {
fail(row, '简介最多 1000 个字符');
}
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
}
if (
!TEMPLATE_UPLOAD_RUNTIMES.includes(
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
)
) {
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
}
if (!ENTRY_PATTERN.test(row.entry.trim())) {
fail(row, 'entry 必须是模板包内的相对路径');
}
if (!row.coverFile) {
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
}
}
return errors;
}
export function buildTemplateImportManifest(
expectedRevision: string,
rows: TemplateUploadRow[],
): AdminImportAgcTemplatesManifest {
return {
expectedRevision,
templates: rows.map((row, index) => {
const item: AdminImportAgcTemplateItemPayload = {
id: row.id.trim(),
title: row.title.trim(),
summary: row.summary.trim(),
tags: parseTemplateTags(row.tags),
runtime: row.runtime,
engine: row.engine.trim(),
engineVersion: row.engineVersion.trim(),
templateVersion: row.templateVersion.trim(),
entry: row.entry.trim(),
zipField: `zip_${index}`,
coverField: `cover_${index}`,
};
return item;
}),
};
}
export function buildTemplateImportFormData(
expectedRevision: string,
rows: TemplateUploadRow[],
): FormData {
const form = new FormData();
form.append(
'manifest',
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
);
rows.forEach((row, index) => {
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
if (row.coverFile) {
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
}
});
return form;
}
+68 -3
View File
@@ -88,10 +88,60 @@
z-index: 1100;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.admin-agc-template-upload-dialog {
border-radius: 10px;
max-width: min(1180px, calc(100vw - 24px));
}
.admin-agc-template-upload-pickers {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.admin-agc-template-upload-picker {
display: grid;
gap: 6px;
font-size: 13px;
}
.admin-agc-template-upload-picker input[type='file'] {
width: 100%;
font-size: 12px;
}
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
.admin-agc-template-upload-scroll {
max-height: min(52vh, 520px);
overflow: auto;
}
.admin-agc-template-upload-file {
font-size: 12px;
word-break: break-all;
}
.admin-agc-template-upload-row-error {
margin-top: 4px;
color: #b3261e;
font-size: 12px;
}
@media (max-width: 680px) {
.admin-agc-template-filters {
grid-template-columns: minmax(0, 1fr);
}
.admin-agc-template-upload-dialog {
max-width: calc(100vw - 12px);
}
}
* {
@@ -1544,15 +1594,15 @@ button:disabled {
}
.admin-project-snapshot-table th:first-child {
width: 20%;
width: 18%;
}
.admin-project-snapshot-table th:nth-child(2) {
width: 14%;
width: 18%;
}
.admin-project-snapshot-table th:nth-child(3) {
width: 18%;
width: 16%;
}
.admin-project-snapshot-table th:nth-child(4) {
@@ -1639,6 +1689,21 @@ button:disabled {
}
}
.admin-project-snapshot-pagination {
justify-content: space-between;
gap: 12px;
}
.admin-project-snapshot-pagination-info {
color: #755a49;
font-size: 13px;
font-weight: 700;
}
.admin-project-snapshot-pagination .admin-field {
min-width: 92px;
}
.admin-recharge-table {
min-width: 1080px;
table-layout: fixed;
+2
View File
@@ -9,6 +9,7 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -75,6 +76,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -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',
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { inflateSync } from 'node:zlib';
export const appIdentifier = 'world.genarrative.ai-game-creator';
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
export const appIdentifier = AGC_APP_IDENTIFIER;
export const configFileName = 'game-creator.config.json';
export const localConfigFileName = 'game-creator.config.local.json';
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
@@ -14,6 +14,7 @@ import {
resolveReleasePartition,
runTauriBuild,
} from './build-release.mjs';
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
import {
readUpdaterPubkey,
@@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
/**
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
*/
function readProductName() {
const read = (file) =>
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
const base = read('tauri.conf.json');
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
const productName = fs.existsSync(macosPath)
? (read('tauri.macos.conf.json').productName ?? base.productName)
: base.productName;
function resolveProductName(channel) {
const { productName } = resolveChannelInstallIdentity(channel);
assert.ok(
typeof productName === 'string' && productName.trim().length > 0,
'Tauri 配置缺少 productName',
'渠道安装身份缺少 productName',
);
return productName;
}
const productName = readProductName();
const appBundleName = `${productName}.app`;
const updaterArtifactName = `${productName}.app.tar.gz`;
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
assert.equal(
process.env.JENKINS_URL?.length > 0,
@@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
const macTarget = 'aarch64-apple-darwin';
const context = resolveReleaseContext([`--target=${macTarget}`]);
const partition = resolveReleasePartition(context.channel, context.target);
const productName = resolveProductName(context.channel);
const appBundleName = `${productName}.app`;
const updaterArtifactName = `${productName}.app.tar.gz`;
const version = await prepareReleaseVersion(context);
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
@@ -13,6 +13,11 @@ import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
resolveChannelInstallIdentity,
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -88,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
const reservedChannelNames = new Set([
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
]);
export { resolveReleaseChannel } from './channel-identity.mjs';
/**
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
@@ -164,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
throw new Error(`不支持的发布目标:${target}`);
}
export function resolveReleaseChannel(env = process.env) {
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
if (
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
channel.endsWith('-') ||
reservedChannelNames.has(channel) ||
/-(win|mac)$/u.test(channel)
) {
throw new Error(
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
);
}
return channel;
}
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
export function resolveReleasePartition(
channel = resolveReleaseChannel(),
@@ -427,12 +410,19 @@ export function buildTauriBuildArguments(
];
}
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
/**
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
*/
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
const { productName, identifier } = resolveChannelInstallIdentity(channel);
return {
productName,
identifier,
plugins: {
updater: {
endpoints: [updateManifestUrl(channel, target)],
@@ -877,14 +867,19 @@ export async function buildRelease(
args = [],
{
prepareVersion = prepareReleaseVersion,
prepareToolset = prepareNsisToolsetForRelease,
build = runTauriBuild,
generateManifest = generateUpdateManifest,
} = {},
) {
const context = resolveReleaseContext(args);
if (!args.includes('--no-bundle')) await prepareVersion(context);
const bundling = !args.includes('--no-bundle');
if (bundling) await prepareVersion(context);
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
if (bundling) await prepareToolset(context, { bundling });
build(args, context);
if (!args.includes('--no-bundle')) return generateManifest(context);
if (bundling) return generateManifest(context);
}
if (
@@ -37,6 +37,11 @@ import {
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
@@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
plugins: {
updater: {
endpoints: [
@@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
});
});
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
});
assert.deepEqual(resolveChannelInstallIdentity('release'), {
productName: '陶泥儿 Release',
identifier: `${AGC_APP_IDENTIFIER}.release`,
});
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
productName: '陶泥儿 Beta-2',
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
});
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
const identity = resolveChannelInstallIdentity(channel);
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
}
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
assert.throws(
() => resolveChannelInstallIdentity(channel),
/发布渠道无效/u,
);
}
});
test('channel install identity is baked into the same build-time config as the endpoint', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
const config = createChannelConfig('release', windowsTarget);
assert.equal(config.productName, '陶泥儿 Release');
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
assert.match(
config.plugins.updater.endpoints[0],
/\/release-win\/latest\.json$/u,
);
});
});
test('channel products keep first-install selection working under the channel product name', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
try {
const { productName } = resolveChannelInstallIdentity('release');
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
writeFileSync(dmg, 'channel first installation disk image');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
assert.equal(
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
target: 'aarch64-apple-darwin',
version: packageVersion,
}),
dmg,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('packaged renderer receives the same channel as the updater manifest', () => {
const context = resolveReleaseContext([], {
AGC_BUILD_TARGET: windowsTarget,
@@ -590,6 +659,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
assert.deepEqual(steps, ['dev']);
});
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
const events = [];
await buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: (context, options) => {
events.push(`toolset:${context.target}:${options.bundling}`);
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
});
assert.deepEqual(events, [
'version',
`toolset:${windowsTarget}:true`,
'build',
'manifest',
]);
});
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
const events = [];
await assert.rejects(
buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: () => {
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
}),
/NSIS 工具链预置失败/u,
);
assert.deepEqual(events, ['version']);
});
test('--no-bundle 不预置 NSIS 工具链', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target', windowsTarget], {
prepareVersion: () => {
steps.push('version');
},
prepareToolset: () => {
steps.push('toolset');
},
build: () => {
steps.push('build');
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['build']);
});
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
const context = resolveReleaseContext(['--target', windowsTarget]);
const events = [];
@@ -0,0 +1,73 @@
/**
* AGC 渠道 → 安装身份。
*
* 渠道同时决定两件事:
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
* - 安装身份:`productName` 与 `identifier`。
*
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
* 本地项目与运行锁。
*
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
*/
export const AGC_DEFAULT_CHANNEL = 'dev';
export const AGC_PRODUCT_NAME = '陶泥儿';
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
const reservedChannelNames = new Set([
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
]);
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
export function validateReleaseChannel(channel) {
if (
typeof channel !== 'string' ||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
channel.endsWith('-') ||
reservedChannelNames.has(channel) ||
/-(win|mac)$/u.test(channel)
) {
throw new Error(
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
);
}
return channel;
}
export function resolveReleaseChannel(env = process.env) {
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
}
/** 安装身份里的展示后缀:`release` → `Release``beta-2` → `Beta-2`。 */
export function channelDisplaySuffix(channel) {
return validateReleaseChannel(channel)
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('-');
}
/**
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
* 保证同一台设备上不同渠道互不覆盖。
*/
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
validateReleaseChannel(channel);
if (channel === AGC_DEFAULT_CHANNEL) {
return Object.freeze({
productName: AGC_PRODUCT_NAME,
identifier: AGC_APP_IDENTIFIER,
});
}
return Object.freeze({
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
});
}
@@ -27,6 +27,11 @@ import {
appIdentifier,
defaultRealSwarmTestTask,
} from './agent-swarm-test-chat.mjs';
import {
AGC_APP_IDENTIFIER,
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
import {
askHidden,
assertSafeGameCreatorConfigDestination,
@@ -1308,7 +1313,8 @@ if (
}
for (const requiredSource of [
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
'export const appIdentifier = AGC_APP_IDENTIFIER',
"'--swarm-chat'",
"'--autonomous-game-build'",
"'--preview-serve'",
@@ -1319,14 +1325,38 @@ for (const requiredSource of [
}
}
if (tauriConfig.productName !== '陶泥儿') {
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
// 的升级链路与既有安装目录都会断开。
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
throw new Error('AI game creator shell productName drifted');
}
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
throw new Error('AI game creator shell identifier drifted');
}
if (
tauriConfig.productName !== defaultChannelIdentity.productName ||
tauriConfig.identifier !== defaultChannelIdentity.identifier
) {
throw new Error(
'AI game creator shell baseline config must match the default channel identity',
);
}
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
for (const channel of ['release', 'beta-2']) {
const identity = resolveChannelInstallIdentity(channel);
if (
identity.productName === defaultChannelIdentity.productName ||
identity.identifier === defaultChannelIdentity.identifier ||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
) {
throw new Error(`channel install identity not isolated: ${channel}`);
}
}
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
...Object.fromEntries(
@@ -0,0 +1,28 @@
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
//
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
// 把网络问题伪装成打包问题。
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
// 最终失败才写 stderr 并以退出码 1 失败关闭。
const logger = {
log: (message) => console.log(message),
warn: (message) => console.log(`${message}(将重试)`),
};
try {
const result = await ensureNsisToolset({ logger });
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
console.log(
result.reused
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
);
} catch (error) {
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
process.exit(1);
}

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