Compare commits

..

88 Commits

Author SHA1 Message Date
k88936 47f5be12c0 Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m16s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m46s
Project CI / Backend tests (pull_request) Successful in 8m13s
Project CI / Repository checks (pull_request) Successful in 4m55s
Project CI / Frontend tests (pull_request) Successful in 5m53s
Project CI / Native shell tests (pull_request) Successful in 11m8s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
2026-09-22 15:58:22 +08:00
k88936 7c76e7a3f2 同步定价文档里的受控 backfill 口径
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m36s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m14s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m17s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m4s
Project CI / Frontend tests (pull_request) Successful in 4m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m0s
Project CI / Native shell tests (pull_request) Successful in 7m28s
- 更新 2026-06-22 定价配置管理方案:缺模型条目改为继承编译内置默认值,并写明只自定义过 gpt-image-2 价格时的计价差异
- 在 2026-09-18 决策条目上标注该受控 backfill 已被 2026-09-22 决策取代
2026-09-22 14:49:02 +08:00
k88936 84bb9ffd28 模型定价改为默认配置叠加持久化覆盖
- 新增 overlay_editor_generation_pricing,以编译内置默认为基线叠加持久化模型条目
- 删除 backfill_legacy_gpt_image_2_5_pricing 与 backfill_legacy_sfx_pricing,record 加载路径改为直接叠加
- 缺模型的旧 override / record 继承默认档位,写坏的条目仍由 validate() 拦截
- 更新 backfill 相关单测,补 legacy override 继承默认值与非法条目拒绝用例
- 同步 ADR 与决策记录里的受控 backfill 口径
2026-09-22 14:42:34 +08:00
k88936 ff89951a9b 删除失效的图片模型兜底审计链路
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m50s
Project CI / Frontend tests (pull_request) Successful in 4m2s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m1s
Project CI / Native shell tests (pull_request) Successful in 7m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m37s
- platform-image 移除 PlatformImageError::FallbackFailed、recovered_failure_audits()、with_recovered_failure_audits 与 into_final_error
- GeneratedImages 去掉恒为空的 recovered_failure_audits 字段,image_source 两处构造同步收敛
- executor 去掉空 Vec 累积、成功路径空 append 与 finish_image_model_fallback_error 包装,失败分支直接返回原错误
- api-server 去掉成功摘要的 recoveredFailureCount 与两处 recovered audit 落库循环,map_platform_image_error 去掉不可达分支
- 测试移除恒空断言,image_generation_does_not_retry_as_another_model 改为断言终态 Upstream 401
- 同步开发运维文档与 decision-log(含说明 2026-07-21 兜底条目的 recoveredFailureCount 已退役)
2026-09-22 14:17:44 +08:00
k88936 c0d4e76a01 解耦 Tiantoken 与 VectorEngine 的图片请求超时
- AppConfig 新增 tiantoken_image_request_timeout_ms(默认 1000000),env 只读 TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS
- VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS 只写 vector_engine_image_request_timeout_ms,不再被 Tiantoken 变量覆盖
- Tiantoken 图片 client 与 OpenAiImageSettings.request_timeout_ms 改用新字段,两个 provider 各用自己的超时
- config 测试补 Tiantoken / VectorEngine 双向不覆盖断言,deploy env 示例补 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
- 同步开发运维文档与 decision-log
2026-09-22 14:08:09 +08:00
k88936 b0bb71a79e 让 resolve_image_provider 的拒绝原因带上模型名
- resolve_image_provider 错误类型从 &'static str 改为 String,未知模型返回「不支持的图片模型:{model}」
- ensure_provider_matches_model 去掉重复拼接的模型名,避免错误信息出现两遍输入
- openai_image_generation 缺少参考图的本地校验改用显式 match,不再用 unwrap_or 静默假定 Tiantoken
- 补充断言:业务模型名与 unknown-model 的拒绝原因里必须包含被拒绝的模型值
2026-09-22 14:03:19 +08:00
k88936 d95a01cf08 同步图片技能文档的同模型重试说明
- SKILL.md 明确脚本只做同 concrete model 的 408/429/5xx 退避重试,不做跨模型回退
2026-09-22 12:26:24 +08:00
k88936 4f2591b50a 说明 GPT Image 2 尺寸族与 provider 白名单的口径差异
- 为 is_gpt_image_2_family_model 补充文档注释:尺寸钳制族刻意包含业务名 gpt-image-2.5
- 明确 resolve_image_provider 仍坚持只收 concrete key,两处白名单不同是设计意图而非遗漏
2026-09-22 12:22:08 +08:00
k88936 8e92bd2952 修正带参考图的便捷生成入口模型选择
- create_image_generation 在参考图非空时改用 gpt-image-2.5-sunburst-c,与 generate→edits 分支和任务边界 editor_image_generation_concrete_model 判据一致
- 新增 image_generation_with_references_uses_the_edit_concrete_model,断言带参考图的生成请求落到 /v1/images/edits 且 multipart 提交编辑 concrete model
2026-09-22 12:20:49 +08:00
k88936 fb6e00c966 消除前端 legacy gpt-image-2 档位的重复字面量
- EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG 提取 GPT_IMAGE_2_5_TIER_PRICES,gpt-image-2 与 gpt-image-2.5 复用同一份泥点档位
- EDITOR_IMAGE_DIMENSION_OPTIONS 提取 GPT_IMAGE_2_5_DIMENSION_OPTIONS,两个 key 复用同一份尺寸档位
- 修正注释:legacy key 是持久化值的防御性别名,生产读取会先经 normalizeEditorImageModel 归一,不再声称它被正式读路径按旧值使用
2026-09-22 12:19:02 +08:00
k88936 d3822a9a19 补齐图片生成重试测试的同模型断言
- image_generation_does_not_retry_as_another_model 增加两次请求的 model 断言,确认重试仍使用 gpt-image-2.5-flare-c
- 与 image_generation_stays_on_model_after_upstream_502 的断言口径对齐,防止回归成换模型重试
2026-09-22 12:17:22 +08:00
k88936 a4be08d511 修正 nanobanana 空模型被误归一为 GPT 生成模型
- 新增 normalize_nanobanana_model,把空模型字符串统一回落到 gemini-3.1-flash-image-preview
- create_nanobanana_generate_content 改用该判据,空模型不再被 normalize_image_model 改写成 gpt-image-2.5-flare-c 后被 provider 校验拒绝
- nanobanana_generate_content_url 复用同一判据,去掉重复的空值分支
- 新增 nanobanana_model_resolution_defaults_blank_values_to_nanobanana 单测
2026-09-22 12:17:00 +08:00
k88936 e901937bb4 修正图片解析去重测试名与真实覆盖范围
- extract_image_urls_and_b64_values_are_deduped 更名为 extract_image_urls_are_deduped_and_b64_values_keep_payload_order
- 测试名不再暗示 extract_b64_images 具备去重能力,实际只断言 URL 去重与 base64 / inline data 的载荷顺序
2026-09-22 12:14:46 +08:00
k88936 827164ed70 为图片素材脚本补回同模型瞬时错误重试
- 两个脚本新增 fetchJsonWithRetry:对 408/429/5xx 按 1s/2s 退避重试同一 concrete model,最多 3 次,不引入跨模型回退
- requestImagePayload 改用带 requestId 的重试入口,单次 408/429/5xx 不再中断整批生成
- 删除只写不读的 providerBody / providerResponseParse 错误字段,解析失败与缺图改为把响应片段写进错误信息
2026-09-22 12:13:25 +08:00
k88936 0b1caf7280 修正图片脚本中误导性的首选模型变量名
- generate-anthro-cat-illustrations 的 preferredImageModel 重命名为 imageModel,并同步 requestImagePayload 与试运行请求体引用
- generate-template-samples 的 preferredImageModel 重命名为 imageModel,并同步 requestImagePayload 与试运行请求体引用
2026-09-22 12:12:06 +08:00
k88936 a943048093 Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
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 3m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m12s
Project CI / Backend tests (pull_request) Successful in 9m12s
Project CI / Native shell tests (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 7m1s
Project CI / Repository checks (pull_request) Successful in 5m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m4s
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-09-22 11:54:00 +08:00
k88936 46c8573b0e 同步前端图片模型测试到 GPT Image 2.5 重命名
- 6 个 image-editor 测试文件的旧 gpt-image-2 断言改为 gpt-image-2.5 / GPT Image 2.5,覆盖图集导出、元数据弹窗、UI 素材提取模型菜单与生成/快速编辑 dialog 参数快照
- 工作流测试夹具与提取模型按钮改用 IMAGE_MODEL_GPT_IMAGE_2_5 常量,legacy 夹具保留并断言提交边界归一为当前业务模型
- decision-log 2026-09-21 条目补 platform-image 与前端全量测试验证结论
2026-09-22 11:47:15 +08:00
k88936 71a17d6096 修正 platform-image mock 请求体读取竞态
- nanobanana 生成用例的 mock server 改为复用同文件 read_http_request,按 Content-Length 读满请求体再断言,消除「只读到 \r\n\r\n 就断言头后 body」的竞态
- pitfalls 新增「测试内 mock HTTP server 必须按 Content-Length 读满请求体再断言」记录现象、原因与变异验证方式
2026-09-22 11:46:43 +08:00
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
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
1372 changed files with 98672 additions and 82447 deletions
@@ -41,14 +41,3 @@
## 注意
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
## 通用表查询页的枚举展示(2026-09-23 起)
后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析:
- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。
- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。
- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。
- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。
因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。
@@ -5,42 +5,131 @@ description: Guide use of Genarrative's hosted external editor/canvas MCP or asy
# Genarrative External Editor API
Use Genarrative to manage canvas projects and asset-library records, generate images, character animations, videos, and audio, and edit images. Discover the hosted integration at `https://www.genarrative.world/api/external/v1/agent-integration.json`.
Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable HTTP and a Bearer API Key. Read tool descriptions and input schemas from `tools/list`; read the documents below through `resources/read` when needed. The hosted service needs no local MCP server. For direct REST, use the live `/api/external/v1/openapi.json` contract (in this repository: `docs/openapi/genarrative-external-v1.openapi.json`).
Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
## Working with the Service
Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
- Select tools by the requested outcome. Use `find_canvas_projects` and `find_assets` to locate existing context; create projects or folders only when the task needs them. A folder need not have the same name as the project.
- For generation, specify project, library, and `canvasCompletion` fields only as supported by the selected tool and needed for the requested destination. Do not duplicate records already created by generation.
- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
## Workflow
1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
## Essential Invariants
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
## Documentation Navigation
Read the reference relevant to the current operation; exact input fields and enums come from the tool schema or OpenAPI.
Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
| Need | Reference | MCP resource URI |
| --- | --- | --- |
| Choose tools and actions by user intent | [Capability routing](references/capability-routing.md) | `genarrative://external-editor/skill/references/capability-routing.md` |
| Map tool calls to REST operations | [API operations](references/api-operations.md) | `genarrative://external-editor/skill/references/api-operations.md` |
| Configure credentials, upload files, handle retries and deletion | [Authentication and safety](references/authentication-and-safety.md) | `genarrative://external-editor/skill/references/authentication-and-safety.md` |
| Construct requests, poll results, place media, handle warnings | [Requests and outputs](references/requests-and-outputs.md) | `genarrative://external-editor/skill/references/requests-and-outputs.md` |
- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
`genarrative://external-editor/usage` contains the short service instructions; `genarrative://external-editor/openapi` contains the REST contract. This entry is available at `genarrative://external-editor/skill`. Reading a resource does not install the downloadable Skill or its Python helper.
The hosted MCP exposes the same documents through:
## Direct REST and Local Helpers
- `genarrative://external-editor/skill`
- `genarrative://external-editor/skill/references/capability-routing.md`
- `genarrative://external-editor/skill/references/api-operations.md`
- `genarrative://external-editor/skill/references/authentication-and-safety.md`
- `genarrative://external-editor/skill/references/requests-and-outputs.md`
- `genarrative://external-editor/openapi`
When remote MCP is unavailable or local-file orchestration needs a helper, the complete package is available at `GET /api/external/v1/skill.zip`; the raw entry is at `GET /api/external/v1/skill/SKILL.md`. Verify the archive SHA-256 against the integration manifest before installing. The archive includes this entry, four references, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Discovery and documentation downloads are public; MCP and business calls require authentication.
## Hosted Integration Discovery
The Python stdlib helper reads the private API Key file described in [authentication and safety](references/authentication-and-safety.md) and uses the production base URL. For a read-only smoke test:
- Manifest: `GET /api/external/v1/agent-integration.json`.
- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
- OpenAPI: `GET /api/external/v1/openapi.json`.
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
## Python Helper
Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
```json
{
"apiKey": "tnr_sk_..."
}
```
Set restrictive permissions where possible, then smoke-test without printing the key:
```bash
chmod 600 ~/.config/genarrative/external-editor-api.json
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
Its `prepare_canvas_session` convenience method creates or reuses a project and a same-name folder. Use it only when that organization matches the task; it is not a prerequisite for MCP or REST calls. Convenience generation methods wait locally while the server uses short asynchronous submit/status requests. Use `submit_generation`, `get_generation`, and `wait_for_generation` for caller-controlled orchestration; see [requests and outputs](references/requests-and-outputs.md).
For a canvas-backed generation:
Stay within `/api/external/v1` for this integration. Internal workers, queues, admin/profile APIs, and SpacetimeDB endpoints are outside this contract.
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
client.generate_image(
"生成一张 16:9 幻想森林游戏背景",
canvasSession=session,
assetLabel="森林背景",
aspectRatio="16:9",
imageSize="1K",
artSpec={
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿与金色光斑",
"composition": "横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": [],
},
)
```
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
```python
session = client.prepare_canvas_session("去背景画布")
client.remove_background(
"editor-upload/object.png",
source_width=720,
source_height=1280,
canvasSession=session,
assetLabel="去背景结果",
)
```
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
## Guardrails
- Do not change the fixed production base URL in generated examples.
- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
@@ -4,45 +4,6 @@ Use this reference after selecting a capability. Treat `GET /api/external/v1/ope
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
## MCP Tool to API Map
The hosted MCP offers the following tools. Choose the task tool when its action matches the request; the operation tool calls the indicated REST operation directly. Task tools with actions take `{ "action": "...", "input": { ... } }`; tools without actions take the operation fields directly. `idempotencyKey` is top-level in task tools. Operation tools use `body`, `pathParameters`, and `queryParameters` wrappers from their live input schemas. Read the live tool schema and OpenAPI for exact required fields.
| REST operation | Task tool (action) | Operation tool |
| --- | --- | --- |
| `GET /api/external/v1/openapi.json` | — | `get_external_open_api_json` |
| `GET /api/external/v1/editor/projects` | `find_canvas_projects` (`list`) | `list_editor_projects` |
| `GET /api/external/v1/editor/projects/recent` | `find_canvas_projects` (`recent`) | `load_recent_editor_project` |
| `GET /api/external/v1/editor/projects/{projectId}` | `find_canvas_projects` (`get`), `find_assets` (`get_project_resources`), `edit_canvas` (`get`) | `get_editor_project` |
| `POST /api/external/v1/editor/projects` | `manage_canvas_projects` (`create`) | `create_editor_project` |
| `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `manage_canvas_projects` (`rename`) | `rename_editor_project` |
| `DELETE /api/external/v1/editor/projects/{projectId}` | `delete_resources` (`delete_project`) | `delete_editor_project` |
| `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `edit_canvas` (`save_layout`) | `save_editor_project_canvas` |
| `POST /api/external/v1/editor/projects/{projectId}/resources` | `edit_canvas` (`register_resource`) | `create_editor_project_resource` |
| `POST /api/external/v1/assets/direct-upload-tickets` | `prepare_asset_upload` (`create_upload_ticket`) | `create_external_direct_upload_ticket` |
| `POST /api/external/v1/assets/objects/confirm` | `prepare_asset_upload` (`confirm_upload`) | `confirm_external_asset_object` |
| `GET /api/external/v1/assets/read-url` | `find_assets` (`get_download_url`) | `get_external_asset_read_url` |
| `GET /api/external/v1/editor/assets/library` | `find_assets` (`list_library`) | `get_editor_asset_library` |
| `POST /api/external/v1/editor/assets/folders` | `organize_asset_library` (`create_folder`) | `create_editor_asset_folder` |
| `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `organize_asset_library` (`update_folder`) | `update_editor_asset_folder` |
| `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `delete_resources` (`delete_folder`) | `delete_editor_asset_folder` |
| `POST /api/external/v1/editor/assets` | `organize_asset_library` (`create_asset`) | `create_editor_asset` |
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
| `POST /api/external/v1/editor/ui-designs/assets/extractions` | `extract_ui_assets` | `extract_external_editor_ui_design_assets` |
| `POST /api/external/v1/editor/character-animations/generations` | `generate_character_animation` | `generate_external_editor_character_animation` |
| `POST /api/external/v1/editor/videos/generations` | `generate_video` | `generate_external_editor_video` |
| `POST /api/external/v1/editor/audios/sound-effects/generations` | `generate_audio` (`sound_effect`) | `generate_external_editor_sound_effect` |
| `POST /api/external/v1/editor/audios/background-music/generations` | `generate_audio` (`background_music`) | `generate_external_editor_background_music` |
| `GET /api/external/v1/generations/{operationId}` | `check_generation` | `get_external_editor_generation_job` |
The public `agent-integration.json`, `skill/SKILL.md`, and `skill.zip` routes and the MCP transport route are HTTP entry points, not callable MCP tools. The hosted resource URIs remain `genarrative://external-editor/skill`, `genarrative://external-editor/skill/references/capability-routing.md`, `genarrative://external-editor/skill/references/api-operations.md`, `genarrative://external-editor/skill/references/authentication-and-safety.md`, `genarrative://external-editor/skill/references/requests-and-outputs.md`, and `genarrative://external-editor/openapi`.
## Project and Canvas Operations
| Operation | Method and path | Minimum input |
@@ -62,7 +23,7 @@ Project listing supports two views:
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
- Hosted MCP `list_editor_projects` and `find_canvas_projects` (`list`) use `summary`; call `get_editor_project` or `find_canvas_projects` (`get`) after selecting a `projectId` when complete authoritative state is required.
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
## Asset and Upload Operations
@@ -81,7 +42,6 @@ Project listing supports two views:
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
`prepare_asset_upload` handles the ticket and confirmation as separate calls; it does not send local bytes to OSS or automatically register a project resource, asset record, or canvas layer. `manage_canvas_projects` (`create`) likewise does not create a same-name asset folder. Register or organize records only when the task needs them.
## Generation Operations
@@ -90,17 +50,16 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Capability | POST path | Required body fields | Common optional body fields |
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all ten through:
Poll all nine through:
```text
GET /api/external/v1/generations/{operationId}
@@ -110,10 +69,10 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
## Canvas and Library Field Rules
- Pass `projectId` and `canvasCompletion` when the task calls for generated output in a canvas.
- Pass `assetFolderId` plus the relevant label field when the task calls for a library record. Neither destination requires the other, and their names need not match.
- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
- Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume returned artifacts and persisted identities; never create a duplicate first-frame resource or asset.
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
- Reload project/library state after completion when full current state is required.
@@ -142,8 +101,8 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
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.
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- 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.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`.
@@ -21,7 +21,7 @@ Authorization: Bearer <tnr_sk_...>
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
For hosted MCP, configure the Bearer token in the client's private connection settings. For the bundled REST helper, store it outside repositories in the user's private JSON file:
Store it outside repositories in the user's private JSON file:
```text
~/.config/genarrative/external-editor-api.json
@@ -61,7 +61,7 @@ The OpenAPI document, integration manifest, raw Skill entry, and Skill archive a
For each logical generation:
1. Create one printable ASCII key of 1-128 bytes. MCP takes `idempotencyKey` at the top level of the tool arguments (outside `input`); REST takes the `Idempotency-Key` header.
1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
2. Persist the key with the exact request body and returned `operationId`.
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
4. Never allocate a new key merely because the outcome is unknown.
@@ -69,21 +69,15 @@ For each logical generation:
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
Keep the API operation as well as the request and key unchanged across a submission retry. A different tool name does not create a separate idempotency namespace. When an operation ID is known, query `check_generation` directly. A rejected submission is not permission to switch keys and generate again.
`manage_canvas_projects/create`, `edit_canvas/register_resource`, and `organize_asset_library/create_folder` accept optional top-level `idempotencyKey`. Other non-generation actions do not accept it; in particular, `create_asset` is not an idempotent generation submission.
## Local Reference Upload
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
2. Call `prepare_asset_upload` with `action=create_upload_ticket` and the ticket body in `input` (REST: `POST /api/external/v1/assets/direct-upload-tickets`).
2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
4. Call `prepare_asset_upload` with `action=confirm_upload` and the confirmation body in `input` (REST: `POST /api/external/v1/assets/objects/confirm`).
5. Pass the confirmed stable `objectKey` where the selected tool permits it. For operations requiring a registered source, register a project resource or asset first and use its ID.
The MCP tool does not transfer file bytes and does not accept a local path or base64. The client needs an HTTP/file-transfer capability for step 3. Object ownership comes from the API Key; do not supply `ownerUserId`. Confirmation alone creates neither a library record nor a canvas layer.
4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
5. Pass the confirmed stable `objectKey` to the selected editor operation.
For a private reference image, use a ticket body shaped like:
@@ -113,7 +107,7 @@ Confirm with the actual file metadata:
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
For character animation, use source identity and dimensions from the actual selected resource; do not invent an existing canvas layer. The bundled local-file helper can maintain its own stable source label, which is not evidence of a persisted canvas layer.
For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
@@ -132,7 +126,7 @@ Do not print the complete confirmation response if it may contain temporary acce
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
- Never use a Blob URL outside the browser process that created it.
- Use `find_assets` with `action=get_download_url` (REST: `GET /api/external/v1/assets/read-url`) to obtain a short-lived `signedUrl` for display/download. This returns a URL; the client still performs any download.
- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
## Logging and Command Safety
@@ -145,8 +139,6 @@ Do not print the complete confirmation response if it may contain temporary acce
## Scope and Retry Guardrails
- Generation spends account credits. Respect the user's authorized task and scope; do not restart generation merely because a requested derivative is missing.
- For `delete_resources`, identify the precise IDs and obtain authorization for the actual deletion scope. Project deletion cascades to its default canvas and project-resource metadata. Folder deletion moves its assets to the default folder; the default folder cannot be deleted. Deleting a folder or asset record does not delete the underlying OSS file.
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
- Do not bypass upload confirmation or invent an object key.
@@ -10,17 +10,21 @@ Use this reference to translate user intent into a hosted MCP tool or its corres
- Public contract: `GET /api/external/v1/openapi.json`.
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. Choose a task-oriented tool below for ordinary requests, or the corresponding operation tool in [API Operations](api-operations.md) when the request needs direct control of one REST call. All 44 tools remain available. Discover the live tool schema before calling it; OpenAPI remains the field-level authority. The public discovery and Skill download routes are listed below, but are not MCP tools.
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
## Project and Asset Destination
## Canvas Session
Use `find_canvas_projects` (`action=list`, `recent`, or `get`) to locate an existing canvas when the request involves one. Use `manage_canvas_projects` (`create` or `rename`) only when the user needs a project created or renamed. A new project does not create an asset folder automatically. Use `find_assets` and `organize_asset_library` when the task involves library records or folders. A project and folder may have different names, and either may be unnecessary for a standalone generation.
Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
For generation, pass `projectId` with `canvasCompletion` when the result should enter a canvas, and `assetFolderId` with the endpoint's label field when it should enter the library. Use both only when the task requires both destinations. Character animation returns its final resource and asset directly when those destinations are requested; do not duplicate its first frame.
1. List or create a project. When creating one, use the canvas name as `title`.
2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame.
## Art Spec Routing
For a series of related art requests, an optional reusable spec can carry the shared requirements:
Before art generation, normalize the user's request into:
```json
{
@@ -39,48 +43,48 @@ Infer what is already clear and ask only for missing fields that block the selec
## Intent Map
| User intent | MCP tool and action |
| --- | --- |
| Find, open, create, or rename a canvas project | `find_canvas_projects` (`list`, `recent`, `get`); `manage_canvas_projects` (`create`, `rename`) |
| Read project resources or library records | `find_assets` (`get_project_resources`, `list_library`) |
| Create or change folders and asset records | `organize_asset_library` (`create_folder`, `update_folder`, `create_asset`, `update_asset`) |
| Upload a local image/audio/video asset | `prepare_asset_upload` (`create_upload_ticket`), client-side OSS form upload, then `prepare_asset_upload` (`confirm_upload`) |
| Register existing media in a project, read a canvas, or save its full layout | `edit_canvas` (`register_resource`, `get`, `save_layout`) |
| Generate a background, character, spec, UI mockup, or publication image | `generate_image` |
| Retouch an existing image, make a reference variation, or remove its background | `modify_image` (`edit`, `variation`, `remove_background`) |
| Build a transparent icon/game atlas from a registered visual spec | `generate_icon_spritesheet` |
| Generate marked assets from an existing UI design | `extract_ui_assets` |
| Animate a character into frames | `generate_character_animation` |
| Generate video | `generate_video` |
| Generate a sound effect or background music | `generate_audio` (`sound_effect`, `background_music`) |
| Check generation progress or retrieve its result | `check_generation` |
| Obtain temporary access to private media | `find_assets` (`get_download_url`) |
| Delete an exact project, folder, or asset record | `delete_resources` (`delete_project`, `delete_folder`, `delete_asset`) |
For tools with actions, send `{ "action": "...", "input": { ... } }`; place `idempotencyKey` at the top level when supported or required. Tools without actions accept operation fields directly, with `idempotencyKey` at the top level for generation. The direct operation tools use `body`, `pathParameters`, and `queryParameters` wrappers as shown by their live schemas.
| User intent | MCP/REST capability |
| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
| Redraw, retouch, or replace an existing image | Image edit |
| Remove the background from an existing image | Background removal |
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
| Extract marked assets from an existing UI design | UI design asset extraction |
| Animate a character into frames | Character animation generation |
| Generate video | Video generation |
| Generate a sound effect | Sound-effect generation |
| Generate background music/BGM | Background-music generation |
| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm |
| Save viewport/layers | Canvas save |
| Create, load, rename, or delete a canvas | Project operations |
| Organize folders and asset records | Asset-library operations |
| Obtain temporary access to private media | Signed read URL |
| Check generation progress or retrieve its result | Generation query |
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
## Route-Specific Decisions
- Use `modify_image` `edit` when the requested output modifies a registered source image. Use `variation` when reference images should guide a new `quick-edit` image; it is image generation with fixed `kind="quick-edit"`. Use `remove_background` for a static source image. With `projectId`, `targetLayerId` may replace a matching existing layer when no explicit `canvasCompletion` is supplied.
- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
- Character animation requires a real `sourceLayerId`, source image, and dimensions from an existing resource. A local-only file must first be uploaded and registered where needed; do not invent a layer ID.
- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
- `prepare_asset_upload` obtains a ticket and confirms an uploaded object; it does not transfer the file or register a project resource, asset record, or canvas layer. Use `edit_canvas` `register_resource` or `organize_asset_library` `create_asset` only when the task needs those records.
- Use temporary signed URLs only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation as each operation permits.
- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
## Example: Reusable Icon Assets
## AI Game Creator Canonical Visual DAG
When the task needs a visual spec and a reusable icon atlas:
Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
1. Reuse an existing registered `icon-spec`, or generate the requested spec using `generate_image` with `kind=spec` and register it as `assetKind=icon-spec` if necessary.
2. Call `generate_icon_spritesheet` with that registered ID as `referenceId`, concrete `iconDescriptions`, and explicit `sliceMode`. Choose `grid` only for requested equal cells or fixed slots and provide those `gridX`/`gridY` values; otherwise use `connected-components`, optionally with `sliceCount`.
3. Query `check_generation` and inspect the full sheet and actual slices. Preserve warnings; a usable full sheet does not imply that individual slices exist. Use returned slice identities and dimensions rather than guessing crop coordinates.
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
An ordinary UI mockup or uploaded image is not automatically an `icon-spec`. For extracting marked components from a UI design, use `extract_ui_assets`, which includes generation and does not promise pixel-exact cropping.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
## Scope Boundary
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. The hosted MCP also exposes the Skill and OpenAPI resources at `genarrative://external-editor/skill`, its four `skill/references/*.md` URIs, and `genarrative://external-editor/openapi`; keep those URI names unchanged.
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
@@ -4,46 +4,15 @@ Use this reference to build generation payloads, carry canvas/library context, p
## Contents
- [MCP Argument Shapes](#mcp-argument-shapes)
- [Asynchronous Submission](#asynchronous-submission)
- [Polling State Machine](#polling-state-machine)
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
- [Art Spec and Image Request](#art-spec-and-image-request)
- [Local Reference Requests](#local-reference-requests)
- [Compact Completed Result](#compact-completed-result)
- [Warning Semantics](#warning-semantics)
- [Output Handling Checklist](#output-handling-checklist)
## MCP Argument Shapes
Pass these objects as the `arguments` of the named tool in `tools/call`. They are not REST request envelopes.
Single-function tools take business fields directly. For example, `generate_image`:
```json
{
"prompt": "一张横版幻想森林背景,无文字",
"aspectRatio": "16:9",
"imageSize": "1K",
"idempotencyKey": "forest-image-001"
}
```
Multi-function tools take `action` and `input`. A generation key stays outside `input`. For example, `generate_audio`:
```json
{
"action": "sound_effect",
"input": {"prompt": "轻柔的游戏菜单确认音", "duration": 1},
"idempotencyKey": "menu-sound-001"
}
```
Keys above identify distinct example requests; create and persist your own key for each new logical generation. An action with no business fields still requires `input: {}`, such as `find_canvas_projects` with `action=list`. Use only fields belonging to the selected action; do not combine branches. REST examples below use the business body directly and put the key in the HTTP header instead.
MCP returns business data in `structuredContent`, without the REST `data` envelope. Check `isError` before using it; an HTTP-successful MCP exchange can still carry a tool error. Generation acceptance contains an `operationId`, not the final media.
## Asynchronous Submission
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
@@ -81,7 +50,7 @@ Persist the key, exact request body, and `operationId`. If submission outcome is
## Polling State Machine
With MCP, call `check_generation` with `{"operationId":"<returned operationId>"}`. Each call queries once and does not wait for completion. With REST, poll `statusUrl` or `GET /api/external/v1/generations/{operationId}`. Query no faster than `pollAfterMs`:
Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
- `completed`: consume the compact `result` and all warning fields, then stop polling.
@@ -106,10 +75,10 @@ Background removal uses the same submission and polling state machine. `sourceIm
## Canvas and Asset-Library Completion
Choose destinations according to the task. Locate an existing project with `find_canvas_projects` and inspect folders with `find_assets/list_library`; create missing destinations with `manage_canvas_projects/create` and `organize_asset_library/create_folder` only when needed. Project creation does not create a folder. For endpoints that support the requested destinations, include:
For endpoints that support these fields, include:
- `projectId`: target canvas project.
- `assetFolderId`: target asset-library folder; its name need not match the project.
- `assetFolderId`: folder whose label matches the canvas name.
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
- `canvasCompletion`: backend canvas placement instructions.
@@ -133,24 +102,15 @@ A minimal `canvasCompletion` is:
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
## Saving Existing Canvas Layout
1. Call `edit_canvas` with `action=get` and `input.projectId` to read the latest project and canvas revision.
2. Build the intended complete `viewport` and `layers`, preserving unrelated layers. `save_layout` replaces the layout; it is not a one-layer patch.
3. Call `edit_canvas` with `action=save_layout` and `input` containing `projectId`, the read `expectedRevision`, and the complete `viewport` and `layers`.
4. On a revision conflict, reread and reconcile with the current layout before retrying. Do not blindly resend stale layers with a refreshed revision.
`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
## Art Spec and Image Request
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
```json
{
@@ -217,11 +177,11 @@ Image edit/redraw has a stricter main-source identity rule. After upload confirm
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
For character animation, use the selected source identity and actual dimensions. The following is a business-body example; `generate_character_animation` also requires a top-level `idempotencyKey`:
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
```json
{
"sourceLayerId": "<selected source layer ID>",
"sourceLayerId": "external-reference-hero",
"sourceImageSrc": "<confirmed objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
@@ -249,7 +209,7 @@ The completed `result` may contain stable artifact fields such as:
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
- `warning` and `sliceWarning` structures.
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `find_assets/get_download_url` for temporary access to a stable `objectKey` (REST: `/assets/read-url`). Reading a record or obtaining a URL does not itself inspect or download the media.
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
## Warning Semantics
+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 only retry the bundled scripts do is a same-model retry on transient upstream statuses (`408` / `429` / `5xx`, 1s/2s backoff, at most 3 attempts), which never changes the model. 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 imageModel = '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`;
@@ -222,6 +223,15 @@ function inferExtensionFromBytes(bytes) {
return null;
}
// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由
// server-rs 负责,脚本不做任何模型替换。
const transientRetryMaxAttempts = 3;
const transientRetryBaseDelayMs = 1000;
function isTransientProviderStatus(status) {
return status === 408 || status === 429 || status >= 500;
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
@@ -233,22 +243,21 @@ 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;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
throw error;
throw new Error(
`Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`,
);
}
} 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 +265,55 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
async function fetchJsonWithRetry(url, options, timeoutMs, requestId) {
for (let attempt = 1; ; attempt += 1) {
try {
return await fetchJson(url, options, timeoutMs);
} catch (error) {
if (
attempt >= transientRetryMaxAttempts ||
!isTransientProviderStatus(error?.providerStatus)
) {
throw error;
}
const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1);
console.warn(
`Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
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 = imageModel;
const requestBody = {
model,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
};
const payload = await fetchJsonWithRetry(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
entry.id,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${entry.id}`);
throw new Error(
`Tiantoken returned no image for ${entry.id}:${JSON.stringify(payload).slice(0, 600)}`,
);
}
async function downloadUrl(url, timeoutMs) {
@@ -373,7 +356,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 +364,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,9 +391,8 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({
id: entry.id,
title: entry.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
model: imageModel,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
@@ -429,7 +411,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 imageModel = '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`;
@@ -192,6 +193,15 @@ function inferExtensionFromBytes(bytes) {
return null;
}
// Tiantoken 偶发 408/429/5xx 时重试同一个 concrete model;跨模型回退仍由
// server-rs 负责,脚本不做任何模型替换。
const transientRetryMaxAttempts = 3;
const transientRetryBaseDelayMs = 1000;
function isTransientProviderStatus(status) {
return status === 408 || status === 429 || status >= 500;
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
@@ -203,22 +213,21 @@ 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;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
throw error;
throw new Error(
`Tiantoken 返回无法解析的响应体:${error.message};响应片段:${text.slice(0, 600)}`,
);
}
} 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 +235,55 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
async function fetchJsonWithRetry(url, options, timeoutMs, requestId) {
for (let attempt = 1; ; attempt += 1) {
try {
return await fetchJson(url, options, timeoutMs);
} catch (error) {
if (
attempt >= transientRetryMaxAttempts ||
!isTransientProviderStatus(error?.providerStatus)
) {
throw error;
}
const delayMs = transientRetryBaseDelayMs * 2 ** (attempt - 1);
console.warn(
`Tiantoken ${error.providerStatus} on ${requestId}, retrying the same model in ${delayMs}ms (attempt ${attempt + 1}/${transientRetryMaxAttempts})`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
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 = imageModel;
const requestBody = {
model,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
};
const payload = await fetchJsonWithRetry(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
template.id,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${template.id}`);
throw new Error(
`Tiantoken returned no image for ${template.id}:${JSON.stringify(payload).slice(0, 600)}`,
);
}
async function downloadUrl(url, timeoutMs) {
@@ -345,7 +326,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 +334,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,9 +365,8 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
model: imageModel,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
@@ -405,7 +385,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),
}),
+5
View File
@@ -30,3 +30,8 @@ server-rs/.data
server-rs/.spacetimedb
public/generated-*
scripts/loadtest/data/*.local.json
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
-2
View File
@@ -237,8 +237,6 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.world,release 对应 https://www.genarrative.world。
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
# Optional: official VikingDB credentials for regenerating build-tag similarities
+1 -2
View File
@@ -213,8 +213,6 @@ module.exports = {
'!src/services/clipboard.test.ts',
'!src/services/frontendRuntimeConfigService.ts',
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
@@ -365,6 +363,7 @@ module.exports = {
'src/types.ts',
'src/types/**',
'src/uiAssets.ts',
'scripts/loadtest/**',
'packages/shared/src/contracts/jumpHop.ts',
'packages/shared/src/contracts/match3dAgent.ts',
'packages/shared/src/contracts/match3dRuntime.ts',
-41
View File
@@ -98,12 +98,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
ai-game-creator-shell-rust-lane-2:
name: AI game creator shell Rust lane 2/2
@@ -147,12 +141,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
@@ -195,12 +183,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
ai-game-creator-shell-rust-crates:
@@ -309,12 +291,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
backend-tests:
name: Backend tests
@@ -404,12 +380,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
# 以及依赖发布产物的构建 smoke。
@@ -472,12 +442,6 @@ jobs:
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
frontend-tests:
name: Frontend tests
@@ -560,11 +524,6 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Validate CI cache maintenance behavior
run: |
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
node --test scripts/export-ci-npm-download-cache.test.mjs
- name: Run repository checks
run: npm run check:repository-ci
+8 -1
View File
@@ -65,7 +65,6 @@ temp*build*/
/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
**/server-rs/.data/
/public/generated-animations
/public/generated-character-drafts
/public/generated-characters
@@ -89,3 +88,11 @@ nohup.out
spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke/
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json
# Local load-test run artifacts
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
+29 -86
View File
@@ -44,27 +44,39 @@ _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 key;VectorEngine 与 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
**UI 设计文档**:
AGC 本地项目里 `kind=ui-design-doc`、`mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
**设计图**:
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
**UI 工作流步骤**:
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
**工作流检查点日志**:
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
## Language
### Puzzle Clear
@@ -207,74 +219,10 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**逻辑回合**:
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
_Avoid_: Codex 原生回合、原生日志、进程生命周期
**接单**:
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
_Avoid_: 发送成功、命令调用、接口返回
**拒单**:
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
_Avoid_: 回合失败、执行失败、失败事件
**在途回合**:
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、发送队列
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**项目对话输入**:
AGC 项目对话的输入只有自然语言回合(含 `@` 素材引用与附件);需要动作时由 Runtime 工具与确认卡承接,不从输入文本解析控制词。
_Avoid_: 斜杠命令、聊天命令草稿、命令发现列表
**项目命令 id**:
AGC 运行期工具与项目权限策略使用的稳定标识(`GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`),由 Rust 运行期策略校验与 App 权限审计 / 项目前置条件判定消费,不是用户输入语法。
_Avoid_: 把命令 id 当作可输入的聊天命令、为权限位补聊天入口
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
**引用 provider**:
一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。
_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方
**静默 provider**:
不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。
_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支
**引用文本语法**:
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用名**:
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
**引用粘贴解析**:
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
**引用选择器**:
宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。
_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道
**附件芯片**:
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
@@ -290,11 +238,6 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token(`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
@@ -7,15 +7,9 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionGames,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
restoreAdminGameDistributionGame,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
@@ -27,30 +21,6 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
const payload = { entries: [], nextCursor: null };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
expect(
await listAdminAgcTrackingEvents('token', {
userId: 'user+1',
projectId: 'project-1',
cursor: 'page/2',
limit: 50,
}),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
}),
);
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
@@ -484,211 +454,3 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
}),
}),
);
});
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
game: {
id: 'game/1',
title: '测试游戏',
status: 'published',
publicationRevision: 10,
},
replayed: false,
},
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
await listAdminGameDistributionGames(
'admin-token',
{ limit: 80 },
controller.signal,
);
await restoreAdminGameDistributionGame(
'admin-token',
' game/1 ',
' game-restore-key-1 ',
{ expectedPublicationRevision: 9 },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=50',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/restore',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-restore-key-1',
}),
body: JSON.stringify({ expectedPublicationRevision: 9 }),
}),
);
expect(() =>
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 9,
}),
).toThrow('缺少游戏 ID');
expect(() =>
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 9,
}),
).toThrow('恢复幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
-142
View File
@@ -1,8 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -32,12 +30,6 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -414,31 +406,6 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function listAdminAgcTrackingEvents(
token: string,
query: AdminAgcTrackingEventQuery = {},
) {
return request<AdminAgcTrackingEventListResponse>(
`/admin/api/agc/tracking-events${buildQueryString((params) => {
for (const key of [
'userId',
'projectId',
'creativeTaskId',
'agentRunId',
'eventName',
'clientVersion',
'startTime',
'endTime',
'cursor',
] as const) {
appendQueryParam(params, key, query[key]);
}
appendNumericQueryParam(params, 'limit', query.limit);
})}`,
{ token },
);
}
export function listAdminErrorReports(
token: string,
query: {
@@ -1233,115 +1200,6 @@ export function saveAgcModelCatalog(
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
export function listAdminGameDistributionGames(
token: string,
options: { limit?: number } = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? 50;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
: 50;
return request<AdminGameDistributionGameListResponse>(
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
{ token, signal },
);
}
export function restoreAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: AdminGameDistributionRestoreRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('恢复幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionRestoreResponse>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('下架幂等键必须是 1 到 128 个字符');
}
return request<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
-140
View File
@@ -193,10 +193,7 @@ export interface AdminDashboardRangePayload {
export interface AdminDashboardMetricsPayload {
generatedAssets: number;
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
consumedMudPoints: number;
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
refundedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
@@ -823,44 +820,6 @@ export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}
export interface AdminAgcTrackingEventQuery {
userId?: string;
projectId?: string;
creativeTaskId?: string;
agentRunId?: string;
eventName?: string;
clientVersion?: string;
startTime?: string;
endTime?: string;
cursor?: string;
limit?: number;
}
export interface AdminAgcTrackingEventEntry {
eventId: string;
schemaVersion: number;
eventName: string;
eventTime: string;
userId: string;
editorSessionId: string;
projectId: string | null;
creativeTaskId: string | null;
agentRunId: string | null;
agentTurnId: string | null;
status: string | null;
errorCode: string | null;
source: string;
clientVersion: string;
properties: Record<string, unknown>;
batchId: string;
receivedAt: string;
}
export interface AdminAgcTrackingEventListResponse {
entries: AdminAgcTrackingEventEntry[];
nextCursor: string | null;
}
export interface AdminTrackingEventKeyPayload {
eventKey: string;
eventTitle: string;
@@ -958,8 +917,6 @@ export interface AdminRechargeOrderEntryPayload {
productTitle: string;
productKind: string;
amountCents: number;
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
paidAmountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
@@ -999,8 +956,6 @@ export interface AdminUserDetailResponse {
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
cumulativeRechargedCents?: number | null;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
@@ -1088,101 +1043,6 @@ export interface AdminAgcModelCatalog {
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminGameDistributionGameVersionEntry {
versionId: string;
gameId: string;
versionNumber: number;
status: string;
reviewReason: string | null;
packageBytes: number;
packageSha256: string;
createdAt: string;
updatedAt: string;
reviewedAt: string | null;
publishedAt: string | null;
entryUrl: string | null;
}
export interface AdminGameDistributionGameEntry {
gameId: string;
title: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
status: string;
versionCount: number;
playCount: number;
activeVersionId: string | null;
publicationRevision: number;
createdAt: string;
updatedAt: string;
versions: AdminGameDistributionGameVersionEntry[];
}
export interface AdminGameDistributionGameListResponse {
games: AdminGameDistributionGameEntry[];
}
export interface AdminGameDistributionRestoreRequest {
expectedPublicationRevision: number;
}
export interface AdminGameDistributionRestoreResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
-21
View File
@@ -20,7 +20,6 @@ import {
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -28,8 +27,6 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -235,12 +232,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
@@ -316,18 +307,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-management' ? (
<AdminGameManagementPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
-5
View File
@@ -5,7 +5,6 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -14,7 +13,6 @@ import {
ReceiptText,
ShieldCheck,
Star,
Swords,
Table2,
TicketCheck,
TicketPercent,
@@ -41,7 +39,6 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
@@ -52,8 +49,6 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'game-management': Swords,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
+6 -47
View File
@@ -8,22 +8,6 @@ import {
routeHash,
} from './adminRoutes';
test('客户端埋点路由遵守成员页签权限', () => {
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-tracking'],
}).map((route) => route.id),
).toEqual(['agc-tracking']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}).some((route) => route.id === 'agc-tracking'),
).toBe(false);
});
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
id: 'dashboard',
@@ -59,6 +43,12 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -158,37 +148,6 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('后台游戏管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-management',
label: '游戏管理',
hash: '#game-management',
});
expect(resolveAdminRoute('#game-management')).toBe('game-management');
expect(routeHash('game-management')).toBe('#game-management');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
-6
View File
@@ -5,7 +5,6 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'agc-tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
@@ -16,8 +15,6 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'game-management'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -43,7 +40,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
@@ -60,8 +56,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -51,7 +51,6 @@ const detail: AdminUserDetailResponse = {
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
cumulativeRechargedCents: 128800,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
@@ -63,7 +62,6 @@ const detail: AdminUserDetailResponse = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -126,11 +124,7 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('¥1288.00')).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() =>
@@ -139,29 +133,6 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
cumulativeRechargedCents: null,
});
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
'未知',
);
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
@@ -364,7 +364,6 @@ export function AdminUserDetailDialog({
<th>订单</th>
<th>商品</th>
<th>实付</th>
<th>发放泥点</th>
<th>退款</th>
<th>状态</th>
</tr>
@@ -378,13 +377,11 @@ export function AdminUserDetailDialog({
</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>{order.productTitle || order.productId}</td>
<td>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付'}
{order.productTitle || order.productId}
<small>发放 {order.pointsDelta} 泥点</small>
</td>
<td>{order.pointsDelta} 泥点</td>
<td>{formatMoney(order.amountCents)}</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small>欠账 {order.unrecoveredPoints} 泥点</small>
@@ -438,14 +435,6 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
<dt>登录方式</dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt>累计充值</dt>
<dd>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
: '未知'}
</dd>
</div>
<div>
<dt>绑定状态</dt>
<dd>
@@ -339,10 +339,7 @@ test.each([409, 503])(
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
await waitFor(() => expect(document.activeElement).toBe(feedback));
expect(document.activeElement).toBe(feedback);
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);

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