Compare commits

..

80 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
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
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
156 changed files with 3957 additions and 2045 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
@@ -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',
@@ -1192,7 +1192,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
@@ -13,7 +13,7 @@
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可",
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
@@ -23,8 +23,6 @@
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
"scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
"default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。",
"spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief{}",
"ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSSoriginalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。",
"default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
"custom_inspection_focus": "检查重点:{question}",
@@ -36,7 +36,7 @@
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
@@ -7684,7 +7684,7 @@ done
&temp.path().join("host"),
&project,
"turn-0001",
"fixture-request",
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
false,
&super::super::direct_validation::DirectValidationConfig::default(),
)
@@ -3407,7 +3407,7 @@ async fn start_tool_bridge_for_source(
}
#[cfg(test)]
pub(in crate::agent) struct DirectExecutionTestFixture {
pub(crate) struct DirectExecutionTestFixture {
execution: Option<super::direct_execution::ExecutionSessionGuard>,
_host: tempfile::TempDir,
}
@@ -3426,7 +3426,7 @@ impl Drop for DirectExecutionTestFixture {
}
#[cfg(test)]
pub(in crate::agent) async fn direct_execution_fixture(
pub(crate) async fn direct_execution_fixture(
root: &Path,
turn: &str,
) -> DirectExecutionTestFixture {
@@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions,
PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
@@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options(
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
if snapshot.generation_kind == generation_kind
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options)
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options)
&& options.asset_kind == kind
&& options.aspect_ratio == ratio
&& options.image_size == size
@@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at(
Ok(PlatformArtGenerationReferences { canonical, ordered })
}
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
// External Editor validates each description independently (currently at
// 200 Unicode characters). Keep the gameplay context short enough that a
// long creation request cannot reject the atlas before it is queued.
const MAX_DESCRIPTION_CHARS: usize = 200;
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
let category =
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
.count()
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
}
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let actual_chars = normalized.chars().count();
if actual_chars <= max_chars {
return normalized;
pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> {
// External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("图标素材描述不能为空".to_string());
}
if max_chars <= 3 {
return ".".repeat(max_chars);
if prompt.chars().count() > 200 {
return Err("图标素材描述不能超过 200 个字符".to_string());
}
let mut output = normalized
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>();
output.push_str("...");
output
Ok(())
}
fn decode_platform_art_image_with_limits(
@@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration {
pub(in crate::agent) fn admit_platform_art_generation_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
_briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> Result<AdmittedPlatformArtGeneration, String> {
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
let context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard(
@@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
options: &PlatformArtAssetGenerationOptions,
admission: AdmittedPlatformArtGeneration,
) -> Result<GeneratedPlatformArtAsset, String> {
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
let context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
if admission.context != context
@@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at(
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> Result<GeneratedPlatformArtAsset, String> {
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
generate_platform_art_asset_with_runtime_options_at(
@@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
}
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
generate_platform_art_asset_with_runtime_options_at(
@@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
options: &PlatformArtAssetGenerationOptions,
runtime_context: Option<&PlatformArtGenerationRuntimeContext>,
) -> Result<PreparedPlatformArtAssetGeneration, String> {
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
validate_platform_art_icon_prompt(prompt)?;
}
enforce_project_permission_policy(root, "canvas.asset_generate")?;
let persisted_runtime_state = runtime_context
.map(|context| {
@@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
.timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT)
.build()
.map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?;
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
let (
generated,
canvas_context,
@@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
"/api/external/v1/editor/icon-spritesheets/generations",
serde_json::json!({
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"iconDescriptions": [generation_prompt],
"sliceCount": options.slice_count,
"sliceMode": options.slice_mode,
"gridX": options.grid_x,
@@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec(
pub(crate) fn build_platform_art_asset_prompt(
prompt: &str,
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> String {
if options.asset_kind == GameCreationAppAssetKind::Image {
@@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt(
truncate_prompt_context(prompt.trim())
);
}
let art_asset_brief = briefs
.iter()
.flat_map(|brief| brief.role_briefs.iter())
.find(|role_brief| {
role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset"
})
.map(|role_brief| role_brief.markdown.trim())
.filter(|markdown| !markdown.is_empty())
.unwrap_or(prompt_text!("media.default_art_brief"));
format!(
prompt_text!("media.spritesheet_generation"),
truncate_inline(prompt, 240),
truncate_prompt_context(art_asset_brief)
)
// 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。
prompt.trim().to_string()
}
#[cfg(test)]
@@ -8533,15 +8500,15 @@ mod canvas_generation_tests {
}
#[test]
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
// 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同,
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() {
// 图集描述由用户提供,工程化生图提示词由 API Server 添加。
let options = PlatformArtAssetGenerationOptions {
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
..PlatformArtAssetGenerationOptions::default()
};
assert!(
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
assert_eq!(
build_platform_art_asset_prompt("原创收集玩法", &options),
"原创收集玩法"
);
assert_eq!(
platform_art_asset_art_spec(&options)["assetType"],
@@ -8945,7 +8912,7 @@ mod canvas_generation_tests {
asset_label: "失败后可重试素材".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options);
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false)
.expect("standalone retry context");
@@ -9121,8 +9088,8 @@ mod canvas_generation_tests {
asset_label: "图标规范".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options);
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options);
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options);
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options);
let first =
standalone_platform_art_generation_runtime_context(&first_prompt, &options, false)
.expect("first standalone slot context");
@@ -9556,7 +9523,7 @@ mod canvas_generation_tests {
asset_label: "图标规范".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options);
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options);
let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false)
.expect("current standalone context");
let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false)
@@ -9676,8 +9643,7 @@ mod canvas_generation_tests {
asset_label: "另一个图标规范".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let other_prompt =
build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options);
let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options);
let other_context = standalone_platform_art_generation_runtime_context(
&other_prompt,
&other_options,
@@ -10877,7 +10843,7 @@ mod canvas_generation_tests {
let current_prompt = "恢复时使用同一个生成意图";
let current_options = PlatformArtAssetGenerationOptions::default();
let durable_generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
build_platform_art_asset_prompt(current_prompt, &current_options);
let request_body = serde_json::json!({
"prompt": "持久化且必须原样重发的生成正文",
"kind": "spec",
@@ -11143,7 +11109,7 @@ mod canvas_generation_tests {
screen_color: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
let request_body = serde_json::json!({
"prompt": generation_prompt,
"kind": "spec",
@@ -11530,8 +11496,7 @@ mod canvas_generation_tests {
asset_label: "账号隔离测试".to_string(),
..PlatformArtAssetGenerationOptions::default()
};
let generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
let generation_prompt = build_platform_art_asset_prompt(current_prompt, &current_options);
let request_body = serde_json::json!({
"prompt": generation_prompt,
"kind": "spec",
@@ -11739,7 +11704,7 @@ mod canvas_generation_tests {
..PlatformArtAssetGenerationOptions::default()
};
let durable_generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
build_platform_art_asset_prompt(current_prompt, &current_options);
let request_body = serde_json::json!({
"prompt": "持久化的原始生成正文",
"kind": "spec",
@@ -11883,7 +11848,7 @@ mod canvas_generation_tests {
};
let options = PlatformArtAssetGenerationOptions::default();
let old_generation_prompt =
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options);
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options);
let binding_access =
ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None)
.expect("prepare changed-intent binding access");
@@ -12050,7 +12015,7 @@ mod canvas_generation_tests {
screen_color: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
let binding_access =
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
.expect("prepare changed-reference binding access");
@@ -12313,8 +12278,7 @@ mod canvas_generation_tests {
.expect("create accepted failure ledger access");
let current_prompt = "失败恢复提示词";
let current_options = PlatformArtAssetGenerationOptions::default();
let generation_prompt =
build_platform_art_asset_prompt(current_prompt, &[], &current_options);
let generation_prompt = build_platform_art_asset_prompt(current_prompt, &current_options);
let (state, _) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
@@ -12517,7 +12481,7 @@ mod canvas_generation_tests {
screen_color: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
let (_, _, frozen_platform_session) =
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
let frozen_platform_session =
@@ -12831,21 +12795,32 @@ mod canvas_generation_tests {
}
#[test]
fn canonical_art_spritesheet_request_preserves_project_requirements() {
let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法");
assert_eq!(descriptions.len(), 1);
assert!(descriptions[0].contains("原创收集玩法"));
assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定"));
fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() {
let options = PlatformArtAssetGenerationOptions {
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
..PlatformArtAssetGenerationOptions::default()
};
let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}";
validate_platform_art_icon_prompt(prompt).expect("valid multiline description");
assert_eq!(
build_platform_art_asset_prompt(prompt, &options),
"金币\n木制宝箱\n银色钥匙"
);
}
#[test]
fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
assert!(!descriptions.is_empty());
assert!(descriptions
.iter()
.all(|description| description.chars().count() <= 200));
fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() {
let boundary = "🪙".repeat(200);
validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n"))
.expect("200 Unicode characters after Rust trim must pass");
let error = validate_platform_art_icon_prompt(&"🪙".repeat(201))
.expect_err("201 Unicode characters must be rejected instead of truncated");
assert!(error.contains("200"), "{error}");
for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] {
assert!(validate_platform_art_icon_prompt(blank).is_err());
}
validate_platform_art_icon_prompt("\u{feff}")
.expect("BOM is not whitespace under the API Server Rust trim contract");
}
#[tokio::test]
@@ -241,11 +241,72 @@ impl ProjectCommandTree {
#[cfg(not(windows))]
{
let _ = child;
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
if !linux_project_command_group_has_live_members(*pid)? {
return Ok(());
}
}
self.request_owned_group_termination().map(|_| ())
}
}
}
#[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> {
let process_group = i32::try_from(group)
.ok()
.filter(|group| *group > 0)
.ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?;
if unsafe { libc::kill(-process_group, 0) } != 0 {
let error = std::io::Error::last_os_error();
return if error.raw_os_error() == Some(libc::ESRCH) {
Ok(false)
} else {
Err(error)
};
}
for entry in fs::read_dir("/proc")? {
let entry = entry?;
if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
continue;
}
let stat = match fs::read(entry.path().join("stat")) {
Ok(stat) => stat,
Err(error)
if error.kind() == std::io::ErrorKind::NotFound
|| error.raw_os_error() == Some(libc::ESRCH) =>
{
continue;
}
Err(error) => return Err(error),
};
let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态");
// comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。
let end = stat
.windows(2)
.rposition(|pair| pair == b") ")
.ok_or_else(invalid_stat)?;
let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?;
let mut fields = tail.split_whitespace();
let state = fields.next().ok_or_else(invalid_stat)?;
let process_group = fields
.nth(1)
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(invalid_stat)?;
if process_group == group && state != "Z" && state != "X" {
return Ok(true);
}
}
Ok(false)
};
inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}"))
}
#[cfg(any(unix, test))]
fn owned_project_command_group_identity_matches(
expected: Option<&str>,
@@ -2504,6 +2565,67 @@ where
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[tokio::test]
async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() {
use std::os::unix::process::CommandExt;
const TEST: &str =
"command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members";
const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE";
if std::env::var_os(FIXTURE).is_none() {
// subreaper 只影响隔离夹具,避免接管并行测试的子进程。
let output = tokio::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", TEST, "--nocapture"])
.env(FIXTURE, "1")
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
drop(child.stdin.take());
child.wait().await.unwrap();
let live_result = tree.after_main_exit(&mut child).await;
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
let zombie_result = tree.after_main_exit(&mut child).await;
assert_eq!(
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
descendant
);
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
assert!(error.contains("leader 身份未确认"), "{error}");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
tree.after_main_exit(&mut child).await.unwrap();
}
#[test]
fn owned_process_group_refuses_missing_or_reused_leader_identity() {
assert!(owned_project_command_group_identity_matches(
@@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.create")?;
if discover_local_godot_project_root(root)?.is_none() {
return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string());
return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string());
}
let _lock = acquire_project_write_lock(root, "project.create")?;
import_local_godot_project_at(root, project_id.trim(), name.trim())
@@ -4963,6 +4963,9 @@ pub(crate) fn prepare_local_project_asset_generation(
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
if asset_kind == GameCreationAppAssetKind::IconSpritesheet {
crate::agent::validate_platform_art_icon_prompt(prompt)?;
}
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
let reference_asset_ids =
@@ -4972,7 +4975,12 @@ pub(crate) fn prepare_local_project_asset_generation(
let target_category = normalize_platform_art_target_category(target_category)?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
prompt: if asset_kind == GameCreationAppAssetKind::IconSpritesheet {
// 图集只采用 API Server 的 trim + 单条长度合同,不套普通图片的控制字符限制。
prompt.trim().to_string()
} else {
local_project_asset_prompt(prompt)?
},
options: PlatformArtAssetGenerationOptions {
output_path: local_project_asset_single_line(
output_path,
@@ -5076,6 +5084,28 @@ mod local_project_asset_generation_tests {
)
}
#[test]
fn icon_prompt_matches_api_server_single_description_contract() {
for prompt in [
" 金币\n\n宝箱\t钥匙 ".to_string(),
"😀".repeat(200),
"\u{85}金币\u{85}".to_string(),
"\u{feff}".to_string(),
"金币\u{0}宝箱".to_string(),
] {
assert_eq!(
prepare("icon-spritesheet", &prompt)
.expect("valid server description")
.prompt,
prompt.trim()
);
}
for prompt in ["\u{85}\n ".to_string(), "😀".repeat(201)] {
assert!(prepare("icon-spritesheet", &prompt).is_err());
}
assert!(prepare("image", &"".repeat(201)).is_ok());
}
#[test]
fn toolbar_kinds_all_reach_the_shared_generation_channel() {
for kind in [
@@ -942,12 +942,14 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool {
// elevated helper runs in a fresh process, so the in-memory runtime
// config-dir override is unavailable there; recognize the packaged
// path from the user's profile as well.
#[cfg(windows)]
if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path)
{
return true;
}
}
#[cfg(windows)]
for environment_name in ["LOCALAPPDATA", "APPDATA"] {
if let Some(root) = std::env::var_os(environment_name)
.map(PathBuf::from)
@@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata)
}
/// Godot 工程标记:`project.godot` 解析为普通文件即命中。
///
/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与
/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只
/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。
fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
let project_file = root.join("project.godot");
let metadata = match fs::symlink_metadata(&project_file) {
// `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。
let metadata = match fs::metadata(&project_file) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
@@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result<bool, String> {
));
}
};
if godot_metadata_is_link(&metadata) {
return Err(format!(
"Godot 项目文件不能是符号链接或 reparse point{}",
project_file.display()
));
}
if !metadata.is_file() {
return Err(format!(
"Godot 项目文件必须是普通文件:{}",
"Godot 项目文件必须是文件:{}",
project_file.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(format!(
"Godot 项目文件不能是硬链接文件:{}",
project_file.display()
));
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
};
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
let file = fs::File::open(&project_file).map_err(|error| {
format!(
"打开 Godot 项目文件失败:{}: {error}",
project_file.display()
)
})?;
// SAFETY: the structure is plain data initialized by GetFileInformationByHandle.
let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
// SAFETY: file owns a live handle and information is a valid output pointer.
// 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。
if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0
|| information.dwFileAttributes
& (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)
!= 0
{
return Err(format!(
"读取 Godot 项目文件 Windows 身份失败:{}",
std::io::Error::last_os_error()
));
}
if information.nNumberOfLinks != 1 {
return Err(format!(
"Godot 项目文件不能是硬链接文件:{}",
project_file.display()
));
}
}
Ok(true)
}
@@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result<String, S
Ok(name.to_string())
}
/// 把模板自带的 Godot 工程显示名改成用户选择的项目名。
///
/// 只改 `[application]` 段里的 `config/name` 一行:Godot 用它当工程显示名与窗口标题,
/// 工程身份仍是 `project.godot` 所在目录,改显示名不影响工程发现或相对根。找不到该行
/// 时保持模板原样,不猜测插入位置;除这一行以外的字节逐字保留。
pub(crate) fn apply_godot_project_display_name(root: &Path, name: &str) -> Result<(), String> {
let path = root.join("project.godot");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"读取 Godot 工程配置失败:{}: {error}",
path.display()
));
}
};
let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
let ends_with_newline = text.ends_with('\n');
let mut replaced = false;
let mut lines = Vec::new();
for line in text.lines() {
let trimmed = line.trim_start();
if !replaced {
if let Some(rest) = trimmed.strip_prefix("config/name") {
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix('=') {
let value = value.trim();
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
let indent = &line[..line.len() - trimmed.len()];
lines.push(format!(
"{indent}config/name=\"{}\"",
escape_godot_project_string(name)
));
replaced = true;
continue;
}
}
}
}
lines.push(line.to_string());
}
if !replaced {
return Ok(());
}
let mut updated = lines.join(newline);
if ends_with_newline {
updated.push_str(newline);
}
write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置")
}
fn escape_godot_project_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> {
let Some(value) = value else {
return Ok(());
@@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root(
matches.push(validate_godot_project_child_name(&entry.file_name())?);
}
matches.sort();
if matches.len() > 1 {
return Err(format!(
"工作区一层子目录中发现多个 Godot 项目:{}",
matches.join("")
));
}
let Some(relative_root) = matches.pop() else {
// 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序
// 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。
let Some(relative_root) = matches.into_iter().next() else {
return Ok(None);
};
@@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at(
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err("Godot 工作区目录不存在或不是普通文件夹".to_string());
}
let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| {
"所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()
})?;
let godot_project_root = discover_local_godot_project_root(root)?
.ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?;
if project_id.is_empty() {
return Err("项目 ID 不能为空".to_string());
}
@@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() {
}
#[test]
fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() {
fn picks_the_first_direct_child_godot_project_in_name_order() {
let workspace = godot_import_test_path("multiple-children");
write_godot_project(&workspace.join("alpha"), "Alpha");
write_godot_project(&workspace.join("beta"), "Beta");
let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
.expect_err("multiple direct child Godot projects must fail");
let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous")
.expect("multiple direct child Godot projects must resolve deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert!(!workspace.join(".agent").exists());
assert!(!workspace.join("alpha/.agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
assert_manifest_godot_root(&workspace, "alpha");
assert!(workspace.join(".agent/manifest.json").is_file());
// 未选中的候选工程不写任何 AGC 元数据。
assert!(!workspace.join("beta/.agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() {
}
#[test]
fn ambiguous_layout_does_not_rewrite_an_existing_manifest() {
fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() {
let workspace = godot_import_test_path("ambiguous-existing");
init_local_game_project_at(&workspace, "existing-project", "Existing Project")
.expect("initialize existing workspace");
write_godot_project(&workspace.join("game"), "Game");
write_godot_project(&workspace.join("other"), "Other");
let manifest_path = workspace.join(".agent/manifest.json");
let original = fs::read(&manifest_path).expect("read original manifest");
let error = import_local_godot_project_at(&workspace, "ignored", "Ignored")
.expect_err("ambiguous existing workspace must fail");
let result = import_local_godot_project_at(&workspace, "ignored", "Ignored")
.expect("ambiguous existing workspace must calibrate to one candidate");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert_eq!(
fs::read(&manifest_path).expect("read unchanged manifest"),
original
);
assert_eq!(result.manifest.project_id, "existing-project");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
assert_manifest_godot_root(&workspace, "game");
assert!(!workspace.join("game/.agent").exists());
assert!(!workspace.join("other/.agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() {
#[cfg(unix)]
#[test]
fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
fn accepts_symbolic_link_project_marker() {
use std::os::unix::fs::symlink;
let workspace = godot_import_test_path("linked-marker");
@@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() {
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
symlink("real.godot", workspace.join("project.godot")).expect("link project marker");
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect_err("linked project.godot must fail");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("linked project.godot must be accepted");
assert!(error.contains("符号链接"), "{error}");
assert!(!workspace.join(".agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
#[cfg(windows)]
#[test]
fn accepts_windows_hard_link_project_marker() {
let workspace = godot_import_test_path("windows-hard-link-marker");
fs::create_dir_all(&workspace).expect("create hard link marker workspace");
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
fs::hard_link(
workspace.join("real.godot"),
workspace.join("project.godot"),
)
.expect("hard link project marker");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("hard linked project.godot must be accepted");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
#[cfg(windows)]
#[test]
fn accepts_windows_reparse_project_marker() {
let workspace = godot_import_test_path("windows-reparse-marker");
fs::create_dir_all(&workspace).expect("create reparse marker workspace");
fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker");
if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() {
// 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。
fs::remove_dir_all(workspace).ok();
return;
}
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("reparse point project.godot must be accepted");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_manifest_godot_root(&workspace, ".");
fs::remove_dir_all(workspace).ok();
}
@@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() {
#[cfg(unix)]
#[test]
fn rejects_linked_marker_inside_a_regular_child_candidate() {
fn accepts_linked_marker_inside_a_regular_child_candidate() {
use std::os::unix::fs::symlink;
let workspace = godot_import_test_path("linked-child-marker");
@@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() {
fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker");
symlink("real.godot", godot_root.join("project.godot")).expect("link project marker");
let error = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect_err("linked marker in a regular child must fail");
let result = import_local_godot_project_at(&workspace, "linked", "Linked")
.expect("linked marker in a regular child must be accepted");
assert!(error.contains("符号链接"), "{error}");
assert!(!workspace.join(".agent").exists());
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game"));
assert_manifest_godot_root(&workspace, "game");
assert!(!godot_root.join(".agent").exists());
fs::remove_dir_all(workspace).ok();
}
@@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() {
fs::remove_dir_all(root).ok();
}
#[test]
fn rewrites_only_the_godot_display_name_line() {
let root = godot_import_test_path("display-name");
fs::create_dir_all(&root).expect("create project root");
fs::write(
root.join("project.godot"),
"config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n",
)
.expect("write project.godot");
apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name");
assert_eq!(
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
"config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn keeps_a_godot_project_without_a_display_name_line_untouched() {
let root = godot_import_test_path("no-display-name");
fs::create_dir_all(&root).expect("create project root");
let original =
"config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n";
fs::write(root.join("project.godot"), original).expect("write project.godot");
apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error");
assert_eq!(
fs::read_to_string(root.join("project.godot")).expect("read project.godot"),
original
);
fs::remove_dir_all(root).ok();
}
fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) {
let manifest_path = workspace.join(".agent/manifest.json");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
@@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at(
&project_name,
);
}
// Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入
// `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。
if discover_local_godot_project_root(&project_root)?.is_some() {
apply_godot_project_display_name(&project_root, &project_name)?;
return import_local_godot_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
);
}
init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
@@ -1492,6 +1502,49 @@ mod tests {
fs::remove_dir_all(&projects_root).ok();
}
#[test]
fn godot_template_creates_native_project_with_relative_root_and_display_name() {
let cache_root = tempfile::tempdir().expect("temp dir");
let projects_root = unique_projects_root();
let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n";
let archive = build_archive(&[
("project.godot", config_source.as_bytes()),
("scenes/main.tscn", b"[gd_scene format=3]\n"),
]);
let mut summary = sample_summary();
summary.id = "godot-fixture-template".to_string();
summary.runtime = "godot".to_string();
summary.entry = "project.godot".to_string();
summary.zip_size_bytes = archive.len() as u64;
summary.zip_sha256 = sha256_hex(&archive);
let record = install_template_archive(cache_root.path(), &summary, &archive)
.expect("install Godot template");
let result = create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
Some("我的平台跳跃"),
false,
)
.expect("create Godot project from template");
// Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("."));
assert_eq!(result.manifest.name, "我的平台跳跃");
let project_root = Path::new(&result.project_path);
assert!(!project_root.join("game").exists());
assert!(project_root.join("scenes/main.tscn").is_file());
let config =
fs::read_to_string(project_root.join("project.godot")).expect("read project.godot");
assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}");
assert!(
config.contains("run/main_scene=\"res://scenes/main.tscn\""),
"只改显示名,其余行逐字保留:{config}"
);
assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists());
fs::remove_dir_all(&projects_root).ok();
}
#[test]
fn refuses_to_create_project_when_template_is_not_installed() {
let projects_root = tempfile::tempdir().expect("temp dir");
@@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_
)
.await;
assert_eq!(observation.status, "ok");
assert_observation_status(&observation, "ok");
assert!(observation.summary.contains("只作为诊断结果"));
assert!(observation
.detail
@@ -1649,6 +1649,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
},
)
.expect("allow generation");
let execution = direct_execution_fixture(&root, "direct-image-refresh-turn").await;
let listener =
TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver");
let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
@@ -1691,6 +1692,12 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
.await
.expect("read rejected result");
assert_eq!(rejected["isError"], true);
assert!(
rejected["content"][0]["text"]
.as_str()
.is_some_and(|message| message.contains("prompt")),
"{rejected}"
);
assert_eq!(
read_manifest_invalidation_relay_payload_with_deadline(&listener)
.expect_err("rejected generation must not emit a commit")
@@ -1698,6 +1705,7 @@ async fn direct_image_generation_notifies_after_manifest_commit() {
io::ErrorKind::TimedOut
);
drop(bridge);
drop(execution);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
@@ -2532,42 +2540,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() {
}
#[test]
fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() {
fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
// 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。
for child in ["beta", "alpha"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect_err("ambiguous Godot workspace must fail inspection");
let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string())
.expect("multiple direct child Godot projects must resolve deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert!(status.is_godot_project);
assert_eq!(status.godot_project_root.as_deref(), Some("alpha"));
fs::remove_dir_all(root).ok();
}
#[test]
fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() {
fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() {
let root = unique_project_path();
for child in ["alpha", "beta"] {
for child in ["beta", "alpha"] {
let godot_root = root.join(child);
fs::create_dir_all(&godot_root).expect("create nested Godot project");
fs::write(godot_root.join("project.godot"), "[application]\n")
.expect("write nested project.godot");
}
let error = import_local_godot_project(
let result = import_local_godot_project(
root.to_string_lossy().into_owned(),
"ambiguous".to_string(),
"Ambiguous".to_string(),
)
.expect_err("ambiguous Godot workspace must fail before locking");
.expect("ambiguous Godot workspace must import the first candidate deterministically");
assert!(error.contains("多个 Godot 项目"), "{error}");
assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha"));
assert!(root.join(".agent/manifest.json").is_file());
assert!(!root.join("beta/.agent").exists());
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
assert!(!root.join(".agent").exists());
fs::remove_dir_all(root).ok();
}
@@ -3542,7 +3553,8 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
{
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_api_server(None);
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender));
let _platform_session = crate::platform_session::install_test_platform_session(
"toolbar-spritesheet-user",
"editor-toolbar-spritesheet-key",
@@ -3597,7 +3609,7 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
let asset = generate_local_project_asset(
root.to_string_lossy().into_owned(),
"icon-spritesheet".to_string(),
"原创收集玩法素材图集".to_string(),
"\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}".to_string(),
Some("1:1".to_string()),
Some("1K".to_string()),
Some("游戏首版图集".to_string()),
@@ -3608,6 +3620,23 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th
.await
.expect("toolbar spritesheet generation");
let generation_request = request_receiver
.try_iter()
.find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations "))
.expect("captured toolbar spritesheet request");
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(
generation_body["iconDescriptions"],
serde_json::json!(["金币\n木制宝箱\n银色钥匙"]),
"客户端只去除首尾空白,完整保留用户描述,不注入或截断提示词"
);
assert!(asset.local_path.starts_with("assets/canvas-generated/"));
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
@@ -6358,7 +6387,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
&[],
&options,
);
for expected in [
@@ -6400,7 +6428,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
}
#[test]
fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() {
fn art_spritesheet_generation_prompt_preserves_user_description() {
let options = PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spritesheet.png".to_string()),
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
@@ -6409,21 +6437,12 @@ fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_de
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物",
&[],
&options,
);
for expected in [
"原创透明核心美术素材图集",
"严格从用户需求和美术 brief 提取",
"素材类别与数量以当前项目需求为准",
"蛇头、直身、转角、尾部和四类食物",
"角色轮廓、图标排布与配色采用项目原创设计",
] {
assert!(
prompt.contains(expected),
"art generation prompt missing {expected}"
);
}
assert_eq!(
prompt,
"原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物"
);
let art_spec = platform_art_asset_art_spec(&options).to_string();
for expected in [
@@ -166,10 +166,10 @@ export function ActiveProjectRunsPanel({
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
const isCurrent = Boolean(
currentProjectPath &&
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
@@ -215,10 +215,10 @@ export function ActiveProjectRunsPanel({
const elapsed = formatActiveTurnElapsed(turn.startedAt, now);
const isCurrent = Boolean(
currentProjectPath &&
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
projectPathsMatchForInvalidation(
turn.projectPath,
currentProjectPath,
),
);
return (
<li key={`${turn.projectPath}:${turn.turnId}`}>
@@ -4,4 +4,6 @@
* 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
*/
export type DirectThreadRequestKind =
'approval.requested' | 'ask.requested' | 'request.resolved';
| 'approval.requested'
| 'ask.requested'
| 'request.resolved';
@@ -17,6 +17,7 @@ import type {
GameIterationVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/image-editor/editorProjectClient';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput';
@@ -229,9 +230,17 @@ export function ResourceCanvasAssetGenerationPanelView({
},
[],
);
// 提示词上限复用资源编辑模型的同一份口径:图片入口默认 32000,与 Rust
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
// 图集原文作为单条 iconDescriptions,复用网站合同;其余图片入口保持原上限。
const promptMaxLength =
action.assetKind === 'icon-spritesheet'
? EDITOR_ICON_DESCRIPTION_MAX_CHARS
: resourceEditPromptMaxLength('image-reference');
// Rust str::trim 使用 Unicode White_SpaceJS trim 对 NEL/BOM 的定义不同。
const normalizePrompt = (value: string) =>
action.assetKind === 'icon-spritesheet'
? value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '')
: value.trim();
const normalizedPrompt = normalizePrompt(prompt);
/**
* 参考选择只对有真实参考能力的入口呈现。
*
@@ -279,9 +288,13 @@ export function ResourceCanvasAssetGenerationPanelView({
assets: assets ?? [],
})
: [];
const promptTooLong = prompt.trim().length > promptMaxLength;
const promptLength =
action.assetKind === 'icon-spritesheet'
? Array.from(normalizedPrompt).length
: normalizedPrompt.length;
const promptTooLong = promptLength > promptMaxLength;
const promptTooLongError = promptTooLong
? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length}`
? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${promptLength}`
: null;
/**
* 重试面板里「输入被改动过」的判据。
@@ -296,12 +309,12 @@ export function ResourceCanvasAssetGenerationPanelView({
boundRequestRef.current.referenceIds,
referenceAssetIds,
) ||
prompt.trim() !== boundRequestRef.current.prompt.trim() ||
normalizedPrompt !== normalizePrompt(boundRequestRef.current.prompt) ||
assetName.trim() !== boundRequestRef.current.assetName.trim() ||
aspectRatio !== boundRequestRef.current.aspectRatio ||
imageSize !== boundRequestRef.current.imageSize);
const canSubmit =
prompt.trim().length > 0 &&
normalizedPrompt.length > 0 &&
assetName.trim().length > 0 &&
!referenceError &&
!referenceIssue &&
@@ -359,7 +372,6 @@ export function ResourceCanvasAssetGenerationPanelView({
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedAssetName = assetName.trim();
// 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。
if (
@@ -456,7 +468,6 @@ export function ResourceCanvasAssetGenerationPanelView({
aria-label="生成提示词"
rows={3}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}

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