Compare commits

..

41 Commits

Author SHA1 Message Date
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
110 changed files with 2909 additions and 3731 deletions
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. - External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. - Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`. - Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. - Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
+6 -6
View File
@@ -1,11 +1,11 @@
--- ---
name: gpt-image-2-apimart name: gpt-image-2-apimart
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical. description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
--- ---
# gpt-image-2 VectorEngine # GPT Image 2.5 project image workflow
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references. Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
## Workflow ## Workflow
@@ -40,7 +40,7 @@ Default body:
```json ```json
{ {
"model": "gpt-image-2", "model": "gpt-image-2.5",
"prompt": "<prompt>", "prompt": "<prompt>",
"n": 1, "n": 1,
"size": "1024x1024" "size": "1024x1024"
@@ -58,14 +58,14 @@ Content-Type: multipart/form-data
Multipart fields: Multipart fields:
```text ```text
model=gpt-image-2 model=gpt-image-2.5
prompt=<prompt> prompt=<prompt>
n=1 n=1
size=1024x1024 size=1024x1024
image=@reference.png image=@reference.png
``` ```
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part. In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints. Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
@@ -9,8 +9,7 @@ const skillRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(skillRoot, '..', '..', '..'); const repoRoot = path.resolve(skillRoot, '..', '..', '..');
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
const defaultTimeoutMs = 1000000; const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2'; const preferredImageModel = 'gpt-image-2.5';
const fallbackImageModel = 'gpt-image-2-c';
const prompts = [ const prompts = [
{ {
@@ -256,41 +255,8 @@ async function fetchJson(url, options, timeoutMs) {
} }
} }
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, entry) { async function requestImagePayload(env, entry) {
for (const model of [preferredImageModel, fallbackImageModel]) { for (const model of [preferredImageModel]) {
const requestBody = { const requestBody = {
model, model,
prompt: buildPrompt(entry), prompt: buildPrompt(entry),
@@ -322,12 +288,7 @@ async function requestImagePayload(env, entry) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error; throw error;
} catch (error) { } catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { throw error;
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
} }
} }
throw new Error(`VectorEngine returned no image for ${entry.id}`); throw new Error(`VectorEngine returned no image for ${entry.id}`);
@@ -408,7 +369,7 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({ requests: selectedPrompts.map((entry) => ({
id: entry.id, id: entry.id,
title: entry.title, title: entry.title,
fallbackModel: fallbackImageModel, fallbackModel: null,
body: { body: {
model: preferredImageModel, model: preferredImageModel,
prompt: buildPrompt(entry), prompt: buildPrompt(entry),
@@ -18,8 +18,7 @@ const defaultOutDir = path.join(
'puzzle-creation-templates', 'puzzle-creation-templates',
); );
const defaultTimeoutMs = 1000000; const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2'; const preferredImageModel = 'gpt-image-2.5';
const fallbackImageModel = 'gpt-image-2-c';
const args = new Map(); const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) { for (let index = 2; index < process.argv.length; index += 1) {
@@ -226,41 +225,8 @@ async function fetchJson(url, options, timeoutMs) {
} }
} }
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, template) { async function requestImagePayload(env, template) {
for (const model of [preferredImageModel, fallbackImageModel]) { for (const model of [preferredImageModel]) {
const requestBody = { const requestBody = {
model, model,
prompt: buildPrompt(template), prompt: buildPrompt(template),
@@ -294,12 +260,7 @@ async function requestImagePayload(env, template) {
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600); error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error; throw error;
} catch (error) { } catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) { throw error;
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
} }
} }
throw new Error(`VectorEngine returned no image for ${template.id}`); throw new Error(`VectorEngine returned no image for ${template.id}`);
@@ -384,7 +345,7 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({ requests: selectedTemplates.map((template) => ({
id: template.id, id: template.id,
title: template.title, title: template.title,
fallbackModel: fallbackImageModel, fallbackModel: null,
body: { body: {
model: preferredImageModel, model: preferredImageModel,
prompt: buildPrompt(template), prompt: buildPrompt(template),
+29
View File
@@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。 由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果 _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
**图片模型历史值与使用端解析**:
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
**图片 provider 显式路由**:
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
**业务模型**:
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
**具体模型**:
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c`nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
**provider client**:
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API keyVectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
**历史模型值**:
已持久化的 `gpt-image-2``gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**: **系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。 一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -501,41 +501,6 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
); );
} }
export function selectFirstInstallArtifact(
files,
{ target, version, artifact },
) {
validateReleaseTarget(target);
let selected;
if (target.includes('windows')) {
selected = artifact;
if (!selected?.endsWith('.exe')) {
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
}
} else {
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const suffix = `_${version}_${architecture}.dmg`;
const candidates = files.filter((file) =>
path.basename(file).endsWith(suffix),
);
if (candidates.length !== 1) {
throw new Error(
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length}`,
);
}
selected = candidates[0];
}
if (
!fs.existsSync(selected) ||
!fs.statSync(selected).isFile() ||
fs.statSync(selected).size === 0
) {
throw new Error(`首装包不存在或为空:${selected}`);
}
return selected;
}
function readUpdaterSignature(artifactPath) { function readUpdaterSignature(artifactPath) {
const signaturePath = `${artifactPath}.sig`; const signaturePath = `${artifactPath}.sig`;
if (!fs.existsSync(signaturePath)) { if (!fs.existsSync(signaturePath)) {
@@ -556,32 +521,23 @@ export function createUpdateManifest(
publishedAt = new Date().toISOString(), publishedAt = new Date().toISOString(),
notes = readReleaseNotes(), notes = readReleaseNotes(),
commit = readHeadCommit(), commit = readHeadCommit(),
downloadArtifact,
} = {}, } = {},
) { ) {
validateReleaseTarget(target); validateReleaseTarget(target);
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
const signature = readUpdaterSignature(artifactPath); const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version; const version = readPackageJson().version;
const firstInstallArtifact = selectFirstInstallArtifact(
downloadArtifact ? [downloadArtifact] : [],
{ target, version, artifact: artifactPath },
);
const fileName = path.basename(artifactPath); const fileName = path.basename(artifactPath);
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
const platforms = {}; const platforms = {};
const downloads = {};
for (const key of resolveManifestPlatformKeys(target)) { for (const key of resolveManifestPlatformKeys(target)) {
platforms[key] = { signature, url }; platforms[key] = { signature, url };
downloads[key] = { url: downloadUrl };
} }
return { return {
version, version,
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
pub_date: publishedAt, pub_date: publishedAt,
platforms, platforms,
downloads,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
...(commit ? { commit } : {}), ...(commit ? { commit } : {}),
}; };
@@ -720,16 +676,10 @@ export async function generateUpdateManifest(
context = resolveReleaseContext(), context = resolveReleaseContext(),
) { ) {
const { channel, target, bundleRoot } = context; const { channel, target, bundleRoot } = context;
const files = listFiles(bundleRoot); const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
const artifact = selectReleaseArtifact(files, target);
if (!artifact) { if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
} }
const downloadArtifact = selectFirstInstallArtifact(files, {
target,
version: readPackageJson().version,
artifact,
});
const manualNotes = readReleaseNotes(); const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel); const previousCommit = await resolvePreviousReleaseCommit(channel);
const commits = collectReleaseCommits(previousCommit); const commits = collectReleaseCommits(previousCommit);
@@ -743,12 +693,7 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`, `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
); );
} }
const manifest = createUpdateManifest(artifact, { const manifest = createUpdateManifest(artifact, { channel, target, notes });
channel,
target,
notes,
downloadArtifact,
});
const manifestPath = path.join(bundleRoot, 'latest.json'); const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt'); const notesPath = path.join(bundleRoot, 'release-notes.txt');
@@ -773,7 +718,6 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
); );
console.log(`[ai-game-creator-shell] 安装包:${artifact}`); console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
console.log( console.log(
manualNotes manualNotes
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
@@ -790,7 +734,6 @@ export async function generateUpdateManifest(
return { return {
channel, channel,
artifact, artifact,
downloadArtifact,
manifest, manifest,
manifestPath, manifestPath,
notes, notes,
@@ -32,23 +32,12 @@ import {
resolveReleaseContext, resolveReleaseContext,
resolveRemoteHighWaterVersion, resolveRemoteHighWaterVersion,
runTauriBuild, runTauriBuild,
selectFirstInstallArtifact,
selectReleaseArtifact, selectReleaseArtifact,
updateManifestUrl, updateManifestUrl,
} from './build-release.mjs'; } from './build-release.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc'; const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin'; const universalTarget = 'universal-apple-darwin';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
function createDmgFixture(root, target, version = packageVersion) {
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
writeFileSync(dmg, 'first installation disk image');
return dmg;
}
test('native sidecar builds reject universal targets and accept each macOS architecture', () => { test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
@@ -265,13 +254,7 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
), ),
artifact, artifact,
); );
const manifest = createUpdateManifest(artifact, { const manifest = createUpdateManifest(artifact, context);
...context,
downloadArtifact: createDmgFixture(
path.dirname(artifact),
context.target,
),
});
assert.deepEqual(Object.keys(manifest.platforms), [ assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64', 'darwin-aarch64',
]); ]);
@@ -289,118 +272,29 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
assert.ok(seenContexts.every((context) => context === seenContexts[0])); assert.ok(seenContexts.every((context) => context === seenContexts[0]));
}); });
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => { const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const downloadArtifact = createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.downloadArtifact, downloadArtifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
const key = target.startsWith('aarch64')
? 'darwin-aarch64'
: 'darwin-x86_64';
assert.deepEqual(Object.keys(result.manifest.platforms), [key]);
assert.deepEqual(Object.keys(result.manifest.downloads), [key]);
assert.match(
result.manifest.platforms[key].url,
/\/dev-mac\/.*\.app\.tar\.gz$/,
);
assert.equal(
decodeURIComponent(
new URL(result.manifest.downloads[key].url).pathname,
),
`/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`,
);
assert.deepEqual(
JSON.parse(readFileSync(result.manifestPath, 'utf8')),
result.manifest,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-'));
try {
const target = 'aarch64-apple-darwin';
const options = {
target,
version: '2.3.4',
artifact: path.join(root, '陶泥儿.app.tar.gz'),
};
const oldVersion = createDmgFixture(root, target, '2.3.3');
const wrongArchitecture = createDmgFixture(
root,
'x86_64-apple-darwin',
'2.3.4',
);
assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u);
assert.throws(
() =>
selectFirstInstallArtifact([oldVersion, wrongArchitecture], options),
/找到 0 个/u,
);
const current = createDmgFixture(root, target, '2.3.4');
assert.equal(
selectFirstInstallArtifact(
[oldVersion, wrongArchitecture, current],
options,
),
current,
);
writeFileSync(current, '');
assert.throws(
() => selectFirstInstallArtifact([current], options),
/不存在或为空/u,
);
writeFileSync(current, 'valid dmg');
const second = path.join(root, '另一包_2.3.4_aarch64.dmg');
writeFileSync(second, 'ambiguous dmg');
assert.throws(
() => selectFirstInstallArtifact([current, second], options),
/找到 2 个/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-'));
try { try {
const artifact = path.join(root, '陶泥儿.app.tar.gz'); const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'updater archive'); writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'signature'); writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const context = { const context = {
...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}), ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
bundleRoot: root, bundleRoot: root,
}; };
await assert.rejects( const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context), () => generateUpdateManifest(context),
/首装 DMG 必须唯一匹配/u,
); );
assert.throws(() => readFileSync(path.join(root, 'latest.json')), { assert.equal(result.artifact, artifact);
code: 'ENOENT', assert.equal(result.manifestPath, path.join(root, 'latest.json'));
}); assert.equal(result.legacyManifestPath, null);
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
} finally { } finally {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
@@ -499,9 +393,6 @@ test('channel manifest carries version, platform keys and signature', () => {
assert.equal(manifest.notes, '修复与改进'); assert.equal(manifest.notes, '修复与改进');
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
assert.deepEqual(manifest.downloads, {
'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url },
});
assert.equal( assert.equal(
manifest.platforms['windows-x86_64'].signature, manifest.platforms['windows-x86_64'].signature,
'signature-content', 'signature-content',
@@ -682,18 +573,18 @@ test('recent commit fallback marks that entries may repeat the previous release'
} }
}); });
test('release entry forwards the built artifacts and dry-run mode to the uploader', () => { test('release upload forces overwrite for artifact, signature and channel pointers', () => {
const source = readFileSync( const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url), new URL('./release-upload.mjs', import.meta.url),
'utf8', 'utf8',
); );
assert.match( assert.equal(
source, (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
/const release = await buildRelease\(process\.argv\.slice\(2\)\)/u, 4,
); );
assert.match(source, /uploadReleaseArtifacts\(release, \{/u); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); assert.match(source, /agc\/latest\.json/u);
assert.ok(source.includes('\n dryRun,\n')); assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
}); });
test('release notes list client commits with short sha and bound their size', () => { test('release notes list client commits with short sha and bound their size', () => {
@@ -1,6 +1,3 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
/** /**
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
@@ -28,89 +25,3 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
} }
return parts.map(quoteArgument).join(' '); return parts.map(quoteArgument).join(' ');
} }
export function createReleaseUploadPlan(
{
artifact,
downloadArtifact,
channel,
manifest,
manifestPath,
legacyManifestPath,
},
bucket,
) {
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
throw new Error('发布结果缺少更新包、首装包或清单');
}
const prefix = `oss://${bucket}/agc/${channel}`;
const artifacts = [
...new Set(
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
path.resolve(file),
),
),
];
const plan = artifacts.map((source) => ({
source,
destination: `${prefix}/${manifest.version}/${path.basename(source)}`,
}));
plan.push({ source: manifestPath, destination: `${prefix}/latest.json` });
if (legacyManifestPath) {
plan.push({
source: legacyManifestPath,
destination: `oss://${bucket}/agc/latest.json`,
});
}
return plan;
}
export function uploadReleaseArtifacts(
release,
{
bucket,
endpoint,
binary = 'ossutil',
accessKeyId,
accessKeySecret,
dryRun = false,
spawn = spawnSync,
log = console.log,
},
) {
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
const plan = createReleaseUploadPlan(release, bucket);
for (const { source, destination } of plan) {
// 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。
const args = ['cp', '--force', source, destination];
if (dryRun) {
log(
`[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`,
);
continue;
}
const credentials = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawn(
binary,
[...args, '--endpoint', endpoint, ...credentials],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error)
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
if (result.status !== 0) {
throw new Error(
`OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`,
);
}
log(`[ai-game-creator-shell] 已上传 ${destination}`);
}
if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
return plan;
}
@@ -1,15 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test'; import { test } from 'node:test';
import { import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
createReleaseUploadPlan,
formatOssutilCommand,
readReleaseDryRun,
uploadReleaseArtifacts,
} from './release-oss.mjs';
test('dry run only accepts explicit truthy values', () => { test('dry run only accepts explicit truthy values', () => {
assert.equal(readReleaseDryRun({}), false); assert.equal(readReleaseDryRun({}), false);
@@ -40,158 +33,12 @@ test('printed upload command keeps arguments and hides credentials', () => {
); );
}); });
function withReleaseFixture(channel, architecture, run) { test('uploader gates every ossutil call behind the dry run switch', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-')); const source = readFileSync(
try { new URL('./release-upload.mjs', import.meta.url),
const artifact = path.join( 'utf8',
root, );
channel === 'dev-win' assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
? '陶泥儿_1.2.3_x64-setup.exe' assert.match(source, /if \(dryRun\) \{/u);
: '陶泥儿.app.tar.gz', assert.match(source, /dry-run:未写入任何 OSS 对象/u);
);
const downloadArtifact =
channel === 'dev-win'
? artifact
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
const manifestPath = path.join(root, 'latest.json');
const legacyManifestPath =
channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null;
for (const file of [
artifact,
`${artifact}.sig`,
downloadArtifact,
manifestPath,
legacyManifestPath,
].filter(Boolean)) {
writeFileSync(file, 'fixture');
}
return run({
artifact,
downloadArtifact,
channel,
manifest: { version: '1.2.3' },
manifestPath,
legacyManifestPath,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}
const uploadOptions = {
bucket: 'agc-dev',
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
log: () => {},
};
for (const architecture of ['aarch64', 'x64']) {
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
withReleaseFixture('dev-mac', architecture, (release) => {
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (binary, args, options) => {
assert.equal(binary, 'ossutil');
assert.equal(options.shell, false);
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push({ source: args[2], destination: args[3] });
return { status: 0 };
},
});
assert.deepEqual(
calls.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.downloadArtifact,
release.manifestPath,
],
);
assert.equal(
calls[2].destination,
`oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`,
);
assert.equal(
calls[3].destination,
'oss://agc-dev/agc/dev-mac/latest.json',
);
});
});
}
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
withReleaseFixture('dev-win', 'x64', (release) => {
const plan = createReleaseUploadPlan(release, 'agc-dev');
assert.deepEqual(
plan.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.manifestPath,
release.legacyManifestPath,
],
);
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push(args[3]);
return { status: 0 };
},
});
assert.deepEqual(
calls,
plan.map(({ destination }) => destination),
);
});
});
for (const failedArtifactIndex of [0, 1, 2]) {
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const destinations = [];
assert.throws(
() =>
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
destinations.push(args[3]);
return {
status: destinations.length - 1 === failedArtifactIndex ? 1 : 0,
};
},
}),
/OSS 上传失败/u,
);
assert.equal(destinations.length, failedArtifactIndex + 1);
assert.ok(
destinations.every(
(destination) => !destination.endsWith('/latest.json'),
),
);
});
});
}
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const output = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
dryRun: true,
accessKeyId: 'fixture-id',
accessKeySecret: 'fixture-secret',
spawn: () => assert.fail('dry run must never execute ossutil'),
log: (line) => output.push(line),
});
assert.equal(
output.filter((line) => line.startsWith('[dry-run]')).length,
4,
);
assert.match(output.join('\n'), /\.dmg/u);
assert.match(output.at(-1), /未写入任何 OSS 对象/u);
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
});
}); });
@@ -1,4 +1,7 @@
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
const endpoint = const endpoint =
@@ -11,12 +14,76 @@ const dryRun = readReleaseDryRun();
const { buildRelease } = await import('./build-release.mjs'); const { buildRelease } = await import('./build-release.mjs');
const release = await buildRelease(process.argv.slice(2)); function runOssutil(args) {
uploadReleaseArtifacts(release, { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
bucket, const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim();
endpoint, const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET;
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil', if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(), throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET, }
dryRun, if (dryRun) {
}); // 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
console.log(
`[dry-run] ${formatOssutilCommand({
binary,
args,
endpoint,
credentials: Boolean(accessKeyId),
})}`,
);
return;
}
const credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawnSync(
binary,
[...args, '--endpoint', endpoint, ...credentialArgs],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error) {
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
await buildRelease(process.argv.slice(2));
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
runOssutil([
'cp',
'--force',
`${artifact}.sig`,
`oss://${bucket}/${artifactKey}.sig`,
]);
runOssutil([
'cp',
'--force',
manifestPath,
`oss://${bucket}/agc/${channel}/latest.json`,
]);
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
console.log(
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
);
if (legacyManifestPath) {
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
runOssutil([
'cp',
'--force',
legacyManifestPath,
`oss://${bucket}/agc/latest.json`,
]);
console.log(
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
);
}
if (dryRun) {
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
}
@@ -23,7 +23,14 @@ import {
normalizeAuthPhoneInput, normalizeAuthPhoneInput,
sendClientPhoneLoginCode, sendClientPhoneLoginCode,
} from '../services/clientAuth'; } from '../services/clientAuth';
import { getClientServerBaseUrl } from '../services/clientHttp'; import {
type ClientServerPreset,
type ClientServerSelection,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import { import {
captureClientError, captureClientError,
installWebviewLogBridge, installWebviewLogBridge,
@@ -151,6 +158,13 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false); const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
useEffect(() => { useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge(); const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => { const handleError = (event: ErrorEvent) => {
@@ -170,6 +184,37 @@ export function AuthenticatedClient({
}; };
}, []); }, []);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return next;
} catch (error) {
void captureClientError(error, {
source: 'auth-hydrate',
action: 'restore-session',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
async function hydrateAuth() { async function hydrateAuth() {
@@ -385,7 +430,11 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) { if (codeBusy || codeCooldownSeconds > 0) {
return; return;
} }
const apiBaseUrl = getClientServerBaseUrl(); const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
const normalizedPhone = normalizeAuthPhoneInput(phone); const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) { if (!normalizedPhone) {
setLoginStatus('请输入手机号'); setLoginStatus('请输入手机号');
@@ -430,7 +479,11 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码'); setLoginStatus('请输入密码');
return; return;
} }
const loginApiBaseUrl = getClientServerBaseUrl(); const persistedSelection = persistServerSelection();
if (!persistedSelection) {
return;
}
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
const loginAttempt = (loginAttemptRef.current += 1); const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true); setLoginBusy(true);
setLoginStatus('正在登录'); setLoginStatus('正在登录');
@@ -582,6 +635,50 @@ export function AuthenticatedClient({
</button> </button>
</div> </div>
) : null} ) : null}
<label>
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
);
}
}
}}
/>
</label>
) : null}
<div className="client-auth-tabs" role="group" aria-label="登录方式"> <div className="client-auth-tabs" role="group" aria-label="登录方式">
<button <button
type="button" type="button"
@@ -9,7 +9,6 @@ import {
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
unwrapApiResponse, unwrapApiResponse,
} from '../../../../packages/shared/src'; } from '../../../../packages/shared/src';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp'; import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { captureClientError } from './errorReporting'; import { captureClientError } from './errorReporting';
import { import {
@@ -17,11 +16,7 @@ import {
requestPlatformSessionRefresh, requestPlatformSessionRefresh,
} from './platformSession'; } from './platformSession';
export { const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
export class ClientAuthRequestError extends Error { export class ClientAuthRequestError extends Error {
readonly status: number | null; readonly status: number | null;
@@ -37,6 +32,23 @@ export class ClientAuthRequestError extends Error {
} }
} }
export function getStoredAuthAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAuthAccessToken(token: string) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
async function readApiErrorMessage( async function readApiErrorMessage(
response: Response, response: Response,
fallback: string, fallback: string,
@@ -29,10 +29,6 @@ import {
} from './clientOperation'; } from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const ACCESS_TOKEN_ORIGIN_STORAGE_KEY =
'genarrative.auth.access-token-origin.v1';
const LEGACY_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
export function normalizeAuthPhoneInput(phone: string) { export function normalizeAuthPhoneInput(phone: string) {
const compactPhone = phone.replace(/[^\d+]/gu, '').trim(); const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
@@ -48,44 +44,21 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
}; };
} }
export function getStoredAuthAccessToken( export function getStoredAuthAccessToken() {
apiBaseUrl = getClientServerBaseUrl(), return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
) {
if (apiBaseUrl !== getClientServerBaseUrl()) return '';
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
const storedOrigin = window.localStorage.getItem(
ACCESS_TOKEN_ORIGIN_STORAGE_KEY,
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish its origin. Recover an unmarked session through the dev cookie.
clearStoredAuthAccessToken();
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return '';
} }
export function setStoredAuthAccessToken( function setStoredAuthAccessToken(token: string) {
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) {
throw new Error('登录凭据不属于客户端固定的 dev 服务');
}
const nextToken = token.trim(); const nextToken = token.trim();
if (nextToken) { if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken); window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return; return;
} }
clearStoredAuthAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
} }
export function clearStoredAuthAccessToken() { export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY);
} }
const clientAuthRefreshPromises = new Map<string, Promise<string>>(); const clientAuthRefreshPromises = new Map<string, Promise<string>>();
@@ -129,7 +102,7 @@ function getClientAuthNetworkErrorMessage(error: unknown) {
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口'; return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
} }
if (/dns|resolve|name or service not known|/iu.test(detail)) { if (/dns|resolve|name or service not known|/iu.test(detail)) {
return '无法连接登录服务:服务器地址无法解析,请检查网络后重试'; return '无法连接登录服务:服务器地址无法解析,请检查服务器选择';
} }
if (/certificate|tls|ssl|/iu.test(detail)) { if (/certificate|tls|ssl|/iu.test(detail)) {
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书'; return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
@@ -229,7 +202,7 @@ async function requestAuthJson<T>(
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) { if (!options.skipAuth) {
const token = getStoredAuthAccessToken(options.apiBaseUrl); const token = getStoredAuthAccessToken();
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
@@ -311,7 +284,7 @@ export async function refreshClientAuthAccessToken(
apiBaseUrl, apiBaseUrl,
transitionClientOperation(operation, 'success'), transitionClientOperation(operation, 'success'),
); );
setStoredAuthAccessToken(response.token, apiBaseUrl); setStoredAuthAccessToken(response.token);
return response.token; return response.token;
}) })
.catch((error) => { .catch((error) => {
@@ -349,7 +322,7 @@ export async function loginClientWithPassword(
'登录失败', '登录失败',
{ skipAuth: true, apiBaseUrl }, { skipAuth: true, apiBaseUrl },
); );
setStoredAuthAccessToken(response.token, apiBaseUrl); setStoredAuthAccessToken(response.token);
return response.user; return response.user;
} }
@@ -392,7 +365,7 @@ export async function loginClientWithPhoneCode(
'登录失败', '登录失败',
{ skipAuth: true, apiBaseUrl }, { skipAuth: true, apiBaseUrl },
); );
setStoredAuthAccessToken(response.token, apiBaseUrl); setStoredAuthAccessToken(response.token);
return response.user; return response.user;
} }
@@ -1,13 +1,14 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client'; export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc'; export const AGC_CLIENT_MARKER_VALUE = 'agc';
/** /**
* Upper bound for the initial network transaction (DNS/connect/response * Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs * headers). Callers may override this for a request that legitimately needs
* more time; the default prevents auth/bootstrap requests from hanging * more time; the default prevents auth/bootstrap requests from hanging
* forever when the platform service is unavailable. * forever when the selected server or proxy is unavailable.
*/ */
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000; export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
@@ -84,12 +85,119 @@ export async function readClientHttpResponseText(
} }
} }
export function getClientServerBaseUrl() { export type ClientServerPreset = 'release' | 'dev' | 'custom';
return AGC_DEVELOPMENT_API_BASE_URL;
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
return import.meta.env.DEV ? 'dev' : 'release';
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
export function getClientServerBaseUrl(
selection: ClientServerSelection = getClientServerSelection(),
) {
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(selection.customBaseUrl);
} }
type ClientHttpContext = { type ClientHttpContext = {
isDevelopment: boolean;
isTauri: boolean; isTauri: boolean;
pageProtocol: string;
mode?: string; mode?: string;
serverBaseUrl?: string; serverBaseUrl?: string;
}; };
@@ -107,7 +215,9 @@ function withAgcClientMarker(init: RequestInit): RequestInit {
function currentClientHttpContext(): ClientHttpContext { function currentClientHttpContext(): ClientHttpContext {
return { return {
isDevelopment: import.meta.env.DEV,
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__), isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
mode: import.meta.env.MODE, mode: import.meta.env.MODE,
}; };
} }
@@ -116,20 +226,20 @@ export function resolveClientHttpTarget(
url: string, url: string,
context: ClientHttpContext = currentClientHttpContext(), context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget { ): ClientHttpTarget {
const serverBaseUrl = getClientServerBaseUrl(); // Existing unit fixtures omit mode; retain the Vite-relative transport for
const target = new URL(url, `${serverBaseUrl}/`); // them while real development/release clients use the selected server.
if ( if (
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) || !context.serverBaseUrl &&
target.origin !== serverBaseUrl || (context.mode === 'test' || (!context.mode && context.isDevelopment))
target.username ||
target.password
) { ) {
throw new Error('请求目标不在客户端固定的 dev 服务范围内'); return { transport: 'web', url };
} }
// Unit fixtures use relative requests after the same origin validation. const serverBaseUrl =
if (context.mode === 'test') { context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
return { transport: 'web', url }; const target = new URL(url, `${serverBaseUrl}/`);
if (target.origin !== serverBaseUrl) {
throw new Error('请求目标不在当前选择的服务器范围内');
} }
if (!context.isTauri) { if (!context.isTauri) {
@@ -148,10 +258,17 @@ export async function fetchClientHttp(
} = {}, } = {},
): Promise<Response> { ): Promise<Response> {
const currentContext = currentClientHttpContext(); const currentContext = currentClientHttpContext();
const target = resolveClientHttpTarget(url, { const serverBaseUrl = options.serverBaseUrl
...currentContext, ? normalizeClientServerBaseUrl(options.serverBaseUrl)
serverBaseUrl: options.serverBaseUrl, : undefined;
}); // Unit fixtures intentionally use the relative Vite transport. Real clients bind every
// auth transaction to the explicit origin captured before its first request.
const target = resolveClientHttpTarget(
url,
currentContext.mode === 'test'
? currentContext
: { ...currentContext, serverBaseUrl },
);
const markedInit = withAgcClientMarker(init); const markedInit = withAgcClientMarker(init);
// Always use a private controller so an internal timeout cannot mutate a // Always use a private controller so an internal timeout cannot mutate a
@@ -1,12 +1,10 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri'; import { resolveTauriInvoke } from '../app/tauri';
import { import {
clearStoredAuthAccessToken,
getCurrentClientAuthUser, getCurrentClientAuthUser,
getStoredAuthAccessToken, getStoredAuthAccessToken,
isClientAuthAuthorityFailure, isClientAuthAuthorityFailure,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth'; } from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp'; import { getClientServerBaseUrl } from './clientHttp';
import { import {
@@ -15,8 +13,10 @@ import {
transitionClientOperation, transitionClientOperation,
} from './clientOperation'; } from './clientOperation';
function readStoredAccessTokenOrThrow(apiBaseUrl: string) { const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const accessToken = getStoredAuthAccessToken(apiBaseUrl);
function readStoredAccessTokenOrThrow() {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) { if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录'); throw new Error('陶泥儿登录凭据缺失,请重新登录');
} }
@@ -94,18 +94,18 @@ export function getPlatformSessionOperation() {
function restoreCommittedAccessToken() { function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) { if (committedPlatformSession?.accessToken) {
setStoredAuthAccessToken( window.localStorage.setItem(
ACCESS_TOKEN_STORAGE_KEY,
committedPlatformSession.accessToken, committedPlatformSession.accessToken,
committedPlatformSession.apiBaseUrl,
); );
return; return;
} }
clearStoredAuthAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
} }
function restoreCurrentRendererAccessToken() { function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) { if (!desiredPlatformSession) {
clearStoredAuthAccessToken(); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
return; return;
} }
restoreCommittedAccessToken(); restoreCommittedAccessToken();
@@ -424,7 +424,7 @@ export async function commitAuthenticatedPlatformSession(
expectedGeneration: number, expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(), apiBaseUrl = resolvePlatformApiBaseUrl(),
) { ) {
const accessToken = readStoredAccessTokenOrThrow(apiBaseUrl); const accessToken = readStoredAccessTokenOrThrow();
const operation = createClientOperation( const operation = createClientOperation(
'auth-transition', 'auth-transition',
{ userId: user.id }, { userId: user.id },
@@ -508,7 +508,7 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
const committed = await enqueuePlatformSessionNativeMutation(() => const committed = await enqueuePlatformSessionNativeMutation(() =>
commitPlatformCredentialRefresh( commitPlatformCredentialRefresh(
user, user,
readStoredAccessTokenOrThrow(apiBaseUrl), readStoredAccessTokenOrThrow(),
apiBaseUrl, apiBaseUrl,
expectedGeneration, expectedGeneration,
), ),
@@ -1,7 +1,11 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { setStoredAuthAccessToken } from '../../src/services/clientAuth'; import {
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
resetClientServerSelectionForTests,
setClientServerSelection,
} from '../../src/services/clientHttp';
import { import {
beginPlatformSessionClearTransition, beginPlatformSessionClearTransition,
beginPlatformSessionTransition, beginPlatformSessionTransition,
@@ -29,6 +33,7 @@ import {
export function registerAuthTests() { export function registerAuthTests() {
afterEach(() => { afterEach(() => {
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
resetClientServerSelectionForTests();
delete window.__TAURI__; delete window.__TAURI__;
}); });
@@ -146,6 +151,7 @@ export function registerAuthTests() {
}); });
it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => { it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
let resolveLogin: ((response: Response) => void) | null = null; let resolveLogin: ((response: Response) => void) | null = null;
@@ -178,12 +184,11 @@ export function registerAuthTests() {
}); });
fireEvent.click(screen.getByRole('button', { name: '登录' })); fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull()); await waitFor(() => expect(resolveLogin).not.toBeNull());
expect(screen.queryByLabelText('服务器')).toBeNull(); expect(
(screen.getByLabelText('服务器') as HTMLSelectElement).disabled,
).toBe(true);
window.localStorage.setItem( setClientServerSelection({ preset: 'release', customBaseUrl: '' });
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveLogin?.( resolveLogin?.(
new Response( new Response(
JSON.stringify({ JSON.stringify({
@@ -206,7 +211,7 @@ export function registerAuthTests() {
); );
expect(invoke).not.toHaveBeenCalledWith( expect(invoke).not.toHaveBeenCalledWith(
'install_platform_account_session', 'install_platform_account_session',
expect.objectContaining({ apiBaseUrl: 'https://www.genarrative.world' }), expect.objectContaining({ apiBaseUrl: AGC_RELEASE_API_BASE_URL }),
); );
}); });
@@ -252,7 +257,10 @@ export function registerAuthTests() {
const installFloor = nativeFloor.revision; const installFloor = nativeFloor.revision;
const installIdentityFloor = nativeFloor.identityGeneration; const installIdentityFloor = nativeFloor.identityGeneration;
const loginGeneration = beginPlatformSessionTransition(); const loginGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('renderer-reload-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'renderer-reload-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration);
expect(mutations[0]?.command).toBe('install_platform_account_session'); expect(mutations[0]?.command).toBe('install_platform_account_session');
expect(mutations[0]?.identityGeneration).toBeGreaterThan( expect(mutations[0]?.identityGeneration).toBeGreaterThan(
@@ -302,7 +310,10 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const firstGeneration = beginPlatformSessionTransition(); const firstGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('retry-floor-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
@@ -310,7 +321,10 @@ export function registerAuthTests() {
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
const secondGeneration = beginPlatformSessionTransition(); const secondGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('retry-floor-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
).resolves.toEqual(expect.any(Number)); ).resolves.toEqual(expect.any(Number));
@@ -344,7 +358,10 @@ export function registerAuthTests() {
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const stalledGeneration = beginPlatformSessionTransition(); const stalledGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('stalled-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'stalled-token',
);
const stalled = commitAuthenticatedPlatformSession( const stalled = commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
stalledGeneration, stalledGeneration,
@@ -353,7 +370,10 @@ export function registerAuthTests() {
expect(installedTokens).toEqual(['stalled-token']); expect(installedTokens).toEqual(['stalled-token']);
const retryGeneration = beginPlatformSessionTransition(); const retryGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('retry-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'retry-token',
);
const retry = commitAuthenticatedPlatformSession( const retry = commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
retryGeneration, retryGeneration,
@@ -457,11 +477,17 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(accountB, accountBGeneration), commitAuthenticatedPlatformSession(accountB, accountBGeneration),
@@ -487,11 +513,17 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -530,11 +562,17 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -575,12 +613,18 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -589,7 +633,10 @@ export function registerAuthTests() {
const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' }; const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' };
const accountCGeneration = beginPlatformSessionTransition(); const accountCGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-c-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-c-token',
);
const accountCCommit = commitAuthenticatedPlatformSession( const accountCCommit = commitAuthenticatedPlatformSession(
accountC, accountC,
accountCGeneration, accountCGeneration,
@@ -614,12 +661,18 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -641,7 +694,10 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition(); const initialGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let resolveRefresh: ((response: Response) => void) | null = null; let resolveRefresh: ((response: Response) => void) | null = null;
@@ -675,7 +731,10 @@ export function registerAuthTests() {
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration); await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
resolveRefresh?.( resolveRefresh?.(
new Response(JSON.stringify({ token: 'late-account-a-token' }), { new Response(JSON.stringify({ token: 'late-account-a-token' }), {
@@ -709,7 +768,10 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition(); const initialGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-a-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let rejectRefresh: ((error: Error) => void) | null = null; let rejectRefresh: ((error: Error) => void) | null = null;
@@ -726,7 +788,10 @@ export function registerAuthTests() {
const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id); const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
setStoredAuthAccessToken('account-b-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration); await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
rejectRefresh?.(new Error('late account A refresh failed')); rejectRefresh?.(new Error('late account A refresh failed'));
@@ -740,7 +805,10 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('expired-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
let refreshCalls = 0; let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
@@ -794,7 +862,10 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('expired-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
const identityGenerationAfterLogin = const identityGenerationAfterLogin =
currentPlatformNativeIdentityGenerationForTests(); currentPlatformNativeIdentityGenerationForTests();
@@ -844,7 +915,10 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('still-valid-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'still-valid-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
const sessionGeneration = currentPlatformSessionGeneration(); const sessionGeneration = currentPlatformSessionGeneration();
@@ -873,10 +947,14 @@ export function registerAuthTests() {
}); });
it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
setStoredAuthAccessToken('expired-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession( await commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
generation, generation,
@@ -905,10 +983,7 @@ export function registerAuthTests() {
}, },
); );
const refresh = requestPlatformSessionRefresh(testAuthUser.id); const refresh = requestPlatformSessionRefresh(testAuthUser.id);
window.localStorage.setItem( setClientServerSelection({ preset: 'release', customBaseUrl: '' });
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveRefresh?.( resolveRefresh?.(
new Response(JSON.stringify({ token: 'replacement-token' }), { new Response(JSON.stringify({ token: 'replacement-token' }), {
status: 200, status: 200,
@@ -999,7 +1074,7 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull();
}); });
it('shows login without server selection or custom platform address', async () => { it('shows release, dev, and custom server choices on the login screen', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => { async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') { if (String(input) === '/api/auth/refresh') {
@@ -1016,74 +1091,22 @@ export function registerAuthTests() {
); );
await screen.findByRole('main', { name: '登录' }); await screen.findByRole('main', { name: '登录' });
expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull(); const server = screen.getByRole('combobox', { name: '服务器' });
expect(screen.queryByLabelText('自定义服务器地址')).toBeNull(); expect(server).not.toBeNull();
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
fireEvent.change(server, { target: { value: 'custom' } });
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'https://staging.example.com' },
});
expect(
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
).toBe('https://staging.example.com');
}); });
it.each(['release', 'dev'])(
'restores dev without forwarding a bare credential despite the %s preference',
async (preset) => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'release-token',
);
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset, customBaseUrl: '' }),
);
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
expect(new Headers(init?.headers).get('Authorization')).not.toBe(
'Bearer release-token',
);
if (url === '/api/auth/refresh') {
expect(
new Headers(init?.headers).get('Authorization'),
).toBeNull();
return new Response(JSON.stringify({ token: 'dev-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
expect(new Headers(init?.headers).get('Authorization')).toBe(
'Bearer dev-token',
);
return new Response(JSON.stringify({ user: testAuthUser }), {
status: 200,
});
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(
await screen.findByRole('main', { name: '已登录' }),
).not.toBeNull();
expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([
'/api/auth/refresh',
'/api/auth/me',
]);
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'dev-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
},
);
it('logs in with a phone code and stores the returned token', async () => { it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
@@ -1422,7 +1445,10 @@ export function registerAuthTests() {
}); });
it('keeps the stored token when startup auth check cannot reach the service', async () => { it('keeps the stored token when startup auth check cannot reach the service', async () => {
setStoredAuthAccessToken('existing-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => { .mockImplementation(async (input: RequestInfo | URL) => {
@@ -1459,7 +1485,10 @@ export function registerAuthTests() {
}); });
it('shows the HTTP maintenance error when startup auth receives a 503', async () => { it('shows the HTTP maintenance error when startup auth receives a 503', async () => {
setStoredAuthAccessToken('existing-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => { async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/me') { if (String(input) === '/api/auth/me') {
@@ -1487,7 +1516,10 @@ export function registerAuthTests() {
}); });
it('still calls logout when token refresh fails during logout retry', async () => { it('still calls logout when token refresh fails during logout retry', async () => {
setStoredAuthAccessToken('existing-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
let logoutCalls = 0; let logoutCalls = 0;
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
@@ -1549,7 +1581,10 @@ export function registerAuthTests() {
}); });
it('fails the renderer closed when native session clear is rejected during logout', async () => { it('fails the renderer closed when native session clear is rejected during logout', async () => {
setStoredAuthAccessToken('existing-token'); window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
window.__TAURI__ = { window.__TAURI__ = {
core: { core: {
invoke: vi.fn(async (command: string) => { invoke: vi.fn(async (command: string) => {
@@ -11,10 +11,7 @@ import {
getStoredAuthAccessToken, getStoredAuthAccessToken,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
} from '../src/services/clientAuth'; } from '../src/services/clientAuth';
import { import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp';
AGC_DEVELOPMENT_API_BASE_URL,
CLIENT_HTTP_DEFAULT_TIMEOUT_MS,
} from '../src/services/clientHttp';
import { import {
cachedLlmModelCatalog, cachedLlmModelCatalog,
refreshLlmModelCatalog, refreshLlmModelCatalog,
@@ -269,18 +266,16 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () =
); );
}); });
const first = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL); const first = refreshClientAuthAccessToken('http://localhost:3000');
const firstAssertion = expect(first).rejects.toThrow(); const firstAssertion = expect(first).rejects.toThrow();
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
await firstAssertion; await firstAssertion;
expect( expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
getClientAuthRefreshOperation(AGC_DEVELOPMENT_API_BASE_URL),
).toMatchObject({
kind: 'auth-refresh', kind: 'auth-refresh',
phase: 'retryable-failure', phase: 'retryable-failure',
}); });
const second = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL); const second = refreshClientAuthAccessToken('http://localhost:3000');
const secondAssertion = expect(second).rejects.toThrow(); const secondAssertion = expect(second).rejects.toThrow();
expect(refreshCalls).toBe(2); expect(refreshCalls).toBe(2);
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
@@ -1,121 +0,0 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getStoredAuthAccessToken as getApiAccessToken,
requestClientApi,
} from '../src/services/clientApi';
import {
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from '../src/services/clientAuth';
import { AGC_DEVELOPMENT_API_BASE_URL } from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const tokenKey = 'genarrative.auth.access-token.v1';
const originKey = 'genarrative.auth.access-token-origin.v1';
const selectionKey = 'genarrative.client.server-selection.v1';
describe('AGC platform credential origin', () => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('preserves a credential already marked as dev', () => {
window.localStorage.setItem(tokenKey, 'existing-dev-token');
window.localStorage.setItem(originKey, AGC_DEVELOPMENT_API_BASE_URL);
expect(getStoredAuthAccessToken()).toBe('existing-dev-token');
expect(getApiAccessToken()).toBe('existing-dev-token');
expect(window.localStorage.getItem(originKey)).toBe(
AGC_DEVELOPMENT_API_BASE_URL,
);
});
it.each([
JSON.stringify({ preset: 'dev', customBaseUrl: '' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: `${AGC_DEVELOPMENT_API_BASE_URL}/`,
}),
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
JSON.stringify({ preset: 'custom', customBaseUrl: 'https://example.com' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: 'http://localhost:8082',
}),
JSON.stringify({ preset: 'unknown', customBaseUrl: '' }),
'invalid-json',
'null',
])(
'never infers a legacy credential origin from a saved preference: %s',
async (selection) => {
window.localStorage.setItem(tokenKey, 'other-server-token');
window.localStorage.setItem(selectionKey, selection);
vi.stubEnv('MODE', 'production');
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ result: true }), { status: 200 }),
);
await requestClientApi('/api/profile/dashboard', {}, '读取失败');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/profile/dashboard`);
expect(new Headers(init?.headers).get('Authorization')).toBeNull();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
},
);
it.each([true, false])(
'clears an unmarked credential without a preference (development=%s)',
(development) => {
// Vitest 0.34 stores stubbed env values as strings; use a falsy value for DEV=false.
vi.stubEnv('DEV', development ? 'true' : '');
window.localStorage.setItem(tokenKey, 'legacy-token');
expect(getStoredAuthAccessToken()).toBe('');
},
);
it('does not relabel a credential that already belongs to another origin', () => {
window.localStorage.setItem(tokenKey, 'other-origin-token');
window.localStorage.setItem(originKey, 'https://www.genarrative.world');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'dev' }),
);
expect(getApiAccessToken()).toBe('');
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
it('stores new dev credentials with their origin and ignores old preferences', () => {
vi.stubEnv('DEV', false);
setStoredAuthAccessToken('new-token');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'release' }),
);
expect(getApiAccessToken()).toBe('new-token');
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe('');
expect(() =>
setStoredAuthAccessToken('wrong-token', 'https://example.com'),
).toThrow('固定的 dev 服务');
expect(getStoredAuthAccessToken()).toBe('new-token');
clearStoredAuthAccessToken();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
});
@@ -1,4 +1,3 @@
/** @vitest-environment jsdom */
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -10,11 +9,16 @@ import {
AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_HEADER,
AGC_CLIENT_MARKER_VALUE, AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL, AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
ClientHttpTimeoutError, ClientHttpTimeoutError,
fetchClientHttp, fetchClientHttp,
getClientServerBaseUrl, getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
readClientHttpResponseText, readClientHttpResponseText,
resetClientServerSelectionForTests,
resolveClientHttpTarget, resolveClientHttpTarget,
setClientServerSelection,
} from '../src/services/clientHttp'; } from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({ vi.mock('@tauri-apps/plugin-http', () => ({
@@ -27,7 +31,7 @@ describe('AGC client HTTP transport', () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
window.localStorage.clear(); resetClientServerSelectionForTests();
}); });
it('adds the AGC marker while preserving and overriding request headers', async () => { it('adds the AGC marker while preserving and overriding request headers', async () => {
@@ -119,25 +123,37 @@ describe('AGC client HTTP transport', () => {
expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token');
}); });
it.each(['development', 'production'])( it('keeps local development requests on the Vite API proxy', () => {
'routes %s Tauri requests through fixed dev',
(mode) => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: true,
mode,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
it('keeps test fixtures on relative requests after origin validation', () => {
expect( expect(
resolveClientHttpTarget('/api/auth/me', { resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: true,
pageProtocol: 'http:',
}),
).toEqual({ transport: 'web', url: '/api/auth/me' });
});
it('routes release Tauri requests through the scoped dev API transport', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`,
});
});
it('keeps ordinary web releases on same-origin relative requests', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: false, isTauri: false,
pageProtocol: 'https:',
mode: 'test', mode: 'test',
}), }),
).toEqual({ transport: 'web', url: '/api/auth/me' }); ).toEqual({ transport: 'web', url: '/api/auth/me' });
@@ -146,55 +162,97 @@ describe('AGC client HTTP transport', () => {
it('rejects release Tauri requests outside the fixed dev API origin', () => { it('rejects release Tauri requests outside the fixed dev API origin', () => {
expect(() => expect(() =>
resolveClientHttpTarget('https://example.com/api/auth/me', { resolveClientHttpTarget('https://example.com/api/auth/me', {
isDevelopment: false,
isTauri: true, isTauri: true,
pageProtocol: 'tauri:',
mode: 'production', mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}), }),
).toThrow('固定的 dev 服务范围'); ).toThrow('当前选择的服务范围');
}); });
it.each(['release', 'dev', 'custom'])( it('persists release, dev, and custom server selection', () => {
'ignores persisted %s preference when resolving web requests', const release = setClientServerSelection({
(preset) => { preset: 'release',
window.localStorage.setItem( customBaseUrl: '',
'genarrative.client.server-selection.v1', });
JSON.stringify({ expect(release).toEqual({
preset, preset: 'release',
customBaseUrl: 'https://staging.example.com', customBaseUrl: '',
}), });
); expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL);
vi.stubEnv('DEV', false); const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL); expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL);
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: false,
mode: 'development',
}),
).toEqual({
transport: 'web',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
it.each(['development', 'production', 'test'])( const custom = setClientServerSelection({
'rejects explicit origin overrides before transport in %s', preset: 'custom',
async (mode) => { customBaseUrl: 'https://staging.example.com/',
vi.stubEnv('MODE', mode); });
const fetchMock = vi.fn(); expect(custom).toEqual({
vi.stubGlobal('fetch', fetchMock); preset: 'custom',
await expect( customBaseUrl: 'https://staging.example.com',
fetchClientHttp( });
'/api/auth/me', expect(getClientServerSelection().preset).toBe('dev');
{}, expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com');
{ });
serverBaseUrl: 'https://www.genarrative.world',
}, it('accepts HTTPS custom servers and loopback HTTP only', () => {
), expect(normalizeClientServerBaseUrl('https://example.com/')).toBe(
).rejects.toThrow('固定的 dev 服务范围'); 'https://example.com',
expect(fetchMock).not.toHaveBeenCalled(); );
expect(tauriHttpFetch).not.toHaveBeenCalled(); expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe(
}, 'http://127.0.0.1:8080',
); );
expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow(
'必须使用 HTTPS',
);
expect(() =>
normalizeClientServerBaseUrl('https://example.com/api'),
).toThrow('纯 HTTP(S)');
});
it('routes selected custom servers for both web and Tauri clients', () => {
const serverBaseUrl = 'https://staging.example.com';
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: false,
pageProtocol: 'http:',
mode: 'development',
serverBaseUrl,
}),
).toEqual({
transport: 'web',
url: `${serverBaseUrl}/api/auth/me`,
});
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl,
}),
).toEqual({
transport: 'tauri-http',
url: `${serverBaseUrl}/api/auth/me`,
});
});
it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'http:',
mode: 'production',
serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
it('aborts a stalled Web request at the configured timeout', async () => { it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
+2 -1
View File
@@ -42,7 +42,7 @@
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
- [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。 - [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。
- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。 - [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定
- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。 - [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -74,6 +74,7 @@
- [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。 - [画板音乐生成入口](./【编辑器】画板音乐生成入口设计-2026-06-18.md):BGM/SFX 共享视图、独立业务规则和当前发布门禁。
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md) - [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)
- [GPT Image 2.5 模型路由与历史值兼容](./adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md)
- [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md)
## 后端、运维与测试 ## 后端、运维与测试
@@ -0,0 +1,19 @@
# GPT Image 2.5 模型路由与历史值兼容
状态:accepted
新任务使用业务模型值 `gpt-image-2.5`api-server 按任务显式选择具体 model:生成使用 `gpt-image-2.5-flare-c`,编辑使用 `gpt-image-2.5-sunburst-c`。这两个 GPT Image 2.5 model 必须通过启动时构造的 Tiantoken client 发送;Tiantoken client 只读取显式配置的 `TIANTOKEN_BASE_URL`(部署值由环境设置为 `https://api.tiantoken.com`)和独立 `TIANTOKEN_API_KEY`,缺失即阻止 api-server 启动,不得回退到 VectorEngine 或其 API key。
图片协议执行逻辑保持 provider-neutral:请求 body、multipart、尺寸约束、重试、响应解码和审计由共享 image executor 承担;VectorEngine 与 Tiantoken client 只提供相同协议所需的 base URL、API key 和 provider identity。platform-image 根据 concrete model 做严格白名单路由:`gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c` 走 Tiantoken`gemini-3.1-flash-image-preview`nanobanana)走 VectorEngine;未知 model 直接拒绝。已持久化的 `gpt-image-2` / `gpt-image-2-c` 只在新任务提交边界按兼容规则解析为当前 GPT Image 2.5 任务,不改写历史资源,也不进入旧 VectorEngine 图片路由。
普通主站前端只接触业务模型和新生成展示名 `GPT Image 2.5`admin Web/API 可以查看和编辑两个具体定价 key;普通生成即使因参考图使用 edits multipart,仍按生成 concrete model。旧 `gpt-image-2-c` 审计记录原样保留,新代码不再跨模型或跨 provider fallback。
主站前端只把原本明确使用 GPT Image 2 的专用新任务改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为保持不变。画布参数或编辑布局读到 `gpt-image-2` / `gpt-image-2-c` 时,在使用端解析为 `gpt-image-2.5` 并触发参数迁移警告,原始资源、审计、metadata 和历史 fixture 不回写。AGC 现有资源生成界面与请求默认保持不变,不因本决策新增模型字段、选择器或尺寸行为。
## Consequences
- 定价配置的活动 key 是两个具体 provider model;旧单 key 配置只允许受控 backfill,并留下兼容 TODO。
- 新任务的同模型重试固定使用 api-server dispatch 的具体 model,不切换到另一个 model。
- 两套 provider client 在 api-server 启动阶段同时构造;任一 required provider 配置缺失,启动失败而不是延迟到首次图片请求。
- provider routing 只依据 concrete model 的白名单;provider client 不复制共享协议执行逻辑。
- 公开资源、`generationInputs` 和普通前端契约不包含具体 provider key;admin 定价管理是明确例外。
@@ -3017,7 +3017,7 @@
}, },
"model": { "model": {
"type": "string", "type": "string",
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时沿用编辑器默认。" "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值在新任务提交边界兼容解析。未传时沿用编辑器默认。"
}, },
"aspectRatio": { "aspectRatio": {
"type": "string", "type": "string",
@@ -3033,7 +3033,7 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。" "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 GPT Image 2.5 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
}, },
"maxItems": 9 "maxItems": 9
}, },
@@ -3170,7 +3170,7 @@
}, },
"model": { "model": {
"type": "string", "type": "string",
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。" "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。"
}, },
"aspectRatio": { "aspectRatio": {
"type": "string", "type": "string",
@@ -3184,7 +3184,7 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" "description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceReferenceId 对应的主来源原图占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
}, },
"maxItems": 8 "maxItems": 8
}, },
@@ -3373,7 +3373,7 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。" "description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceId 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
}, },
"maxItems": 8 "maxItems": 8
}, },
@@ -3488,13 +3488,13 @@
"model": { "model": {
"type": "string", "type": "string",
"default": "gemini-3.1-flash-image-preview", "default": "gemini-3.1-flash-image-preview",
"description": "支持 gpt-image-2、gemini-3.1-flash-image-preview、nanobanana2、nano-banana。未传时默认使用 nanobanana。" "description": "支持 gpt-image-2.5、gemini-3.1-flash-image-preview、nanobanana2、nano-bananagpt-image-2 / gpt-image-2-c 仅作为历史值兼容解析。未传时默认使用 nanobanana。"
}, },
"referenceImageSrcs": { "referenceImageSrcs": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。" "description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 GPT Image 2.5 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
}, },
"maxItems": 5 "maxItems": 5
}, },
@@ -0,0 +1,50 @@
# GPT Image 2.5 provider 边界重构实施计划
- Version: 3
- Status: active
- Date: 2026-09-18
- Parent Milestone: `docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`
## 实施边界
1. 先抽象 provider-neutral settings/client 与共享图片执行器接口;保留一套 body、multipart、尺寸、retry、响应和 audit 逻辑。
2. 在 api-server 配置/state 初始化阶段分别构造 VectorEngine 与 Tiantoken client;删除 Tiantoken 对 VectorEngine URL/key 的任何 fallback。
3. 在 platform-image 建立 concrete model 白名单路由:GPT Image 2.5 → Tiantokennanobanana → VectorEnginelegacy/unknown 按合同处理。
4. 重命名 provider-specific client/build/transport 符号,避免共享逻辑继续伪装成 `vector_engine_*`;仅保留确有 VectorEngine 语义的名称。
5. 迁移 api-server、Agent、raw edit、角色/图标/UI 入口和测试;核对 pricing/admin/public DTO 可见性。
6. 删除跨模型/跨 provider fallback 分支,保留同 concrete model retry。
## 前端同步边界(已确认)
1. 主站 `src/components/image-editor/` 中,凡是原本明确写死 `gpt-image-2` 的 GPT 专用新任务(快速编辑、UI 设计、宣发、规范及对应提交/锁定模型)统一改为业务模型值 `gpt-image-2.5`;普通图片、角色、场景、图标等原有 nanobanana 默认行为不变。
2. `normalizeEditorImageModel` 在编辑面板、画布生成参数和历史布局恢复的使用端把 `gpt-image-2``gpt-image-2-c` 解析为 `gpt-image-2.5`。该兼容命中必须触发既有参数回退警告;未知模型仍按原有无效参数处理。原始资源、审计、metadata 和历史 fixture 不回写。
3. 主站新请求的 `model` 字段只发送业务模型值或 nanobanana 业务值,不发送 `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 等 concrete provider model。
4. AGC`apps/ai-game-creator-shell/src` 及其 Tauri 本地资源生成链路)本里程碑不增加模型字段、选择器或尺寸行为;继续使用现有请求默认值。AGC 代码只在确属历史兼容读取/测试证明的位置保留旧值,不借本次同步引入新功能。
5. OpenAPI、External editor skill reference、现役脚本和当前技术方案中的“新任务使用 GPT Image 2 / 2-c fallback”改为 GPT Image 2.5 口径;静态已生成 manifest、历史审计和兼容 fixture 保持旧值并补充历史语义断言。
## 前端实现顺序
1. 先在 `ImageCanvasGenerationModel` 集中定义历史别名解析与迁移警告结果,保持 nanobanana 默认值、尺寸矩阵和可选模型顺序不变。
2. 迁移 `ImageCanvasGenerationDialogModel``ImageCanvasGenerationSubmissionModel`、生成工作流、快速编辑弹窗、规范/宣发面板的 GPT 专用默认、锁定值和提交 payload。
3.`generationInputs` / 画布布局 / 资源历史恢复补齐旧值迁移警告测试;确认新请求字段为 `gpt-image-2.5`,而历史原文仍未被改写。
4. 更新 OpenAPI 与现役工具/专题文档,区分业务模型、concrete provider model、历史值和产品展示名;不得把 AGC 默认行为改成前端显式模型选择。
## 验证命令
- `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`
- `cargo test -p platform-image`
- `cargo test -p platform-editor-agent`
- api-server 定向测试/`cargo check -p api-server`
- `npm run typecheck`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`
- 主站图片编辑定向测试:模型选项/展示名、GPT 专用提交值、历史 `gpt-image-2``gpt-image-2-c` 恢复及警告、nanobanana 默认回归。
- AGC 定向测试:确认未新增 `model` IPC 入参、未改变原有尺寸/UI 行为,现有资源生成请求继续依赖服务端默认。
- OpenAPI/External editor 契约测试:模型说明、参考图容量说明和新业务模型值一致,provider concrete model 不进入普通前端契约。
## 风险与回滚
- 风险:启动阶段依赖变化、历史任务兼容解析遗漏、nanobanana 被误路由到 Tiantoken、provider key 泄露到公开 DTO。
- 风险:主站把 nanobanana 的默认路径误改成 GPT Image 2.5,或把历史兼容值静默吞掉导致用户无法识别参数迁移。
- 回滚:以 provider-neutral seam、启动配置、model route、主站前端同步四个局部提交边界回滚;不执行数据库历史迁移,也不回滚历史资源值。
@@ -0,0 +1,52 @@
# GPT Image 2.5 provider 边界重构
- Version: 2
- Status: active
- Date: 2026-09-18
- Parent Spec: `docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`
## 目标
在保持图片协议执行逻辑共享的前提下,建立明确的 provider client 边界:GPT Image 2.5 通过 Tiantokennanobanana 通过 VectorEngine;路由依据 concrete model 严格白名单决定;两个 client 在 api-server 启动阶段构造。
## 范围
- `platform-image`provider-neutral 图片执行器、provider client 注入 seam、concrete model 路由和错误/审计 provider 标识。
- `api-server`:启动时构造 VectorEngine/Tiantoken 两个 client,分别读取各自环境变量;任务提交边界的历史模型兼容解析。
- 共享请求、multipart、尺寸、retry、响应和 audit 逻辑保持单一实现。
- Agent 与其它 server-side 图片调用方迁移到业务模型/concrete model 合同。
- 定价、公开 DTO、admin DTO 与 provider model 可见性保持既定 ADR 约束。
## 现役路由
| Concrete model | Provider client | 业务用途 |
| --- | --- | --- |
| `gpt-image-2.5-flare-c` | Tiantoken | Generate,包括带参考图的普通生成 |
| `gpt-image-2.5-sunburst-c` | Tiantoken | Edit,包括快速编辑、原位修改、raw edit |
| `gemini-3.1-flash-image-preview` | VectorEngine | nanobanana 生成/编辑能力 |
`gpt-image-2``gpt-image-2-c` 只保留为历史持久化/审计字符串;新任务不得 dispatch 到旧 GPT Image 2 路由。未知 model 直接拒绝。
## 必须成立的行为
1. api-server 启动时同时构造两个 required provider client;任一对应环境变量缺失,启动失败。
2. Tiantoken 只读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`VectorEngine 只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,互不回退。
3. platform-image 根据 concrete model 选择已注入 client;共享执行器不复制 provider 协议逻辑。
4. 同 concrete model 可以 retry,但永不跨 concrete model 或跨 provider fallback。
5. 历史值读取不改写;新任务提交边界将旧值兼容为 GPT Image 2.5 业务任务。
6. 普通前端不接收 concrete provider modeladmin 定价界面可查看和编辑两个具体 pricing key。
## 非目标
- 不复制两套完整图片 client。
- 不新增 GPT Image 2 现役 VectorEngine 路由。
- 不修改历史数据库记录或旧审计字符串。
- 不把 provider client 选择下沉给普通前端。
## 验收证据
- provider routing 单元测试覆盖 flare/sunburst/nanobanana/legacy/unknown。
- 启动配置测试证明两套 client 独立读取环境变量,缺失任一配置即失败且无 VectorEngine/Tiantoken 回退。
- 请求审计测试证明 provider 与 concrete model 正确记录。
- platform-image 与 Agent 定向测试通过。
- api-server 类型/编译检查、前端类型检查、编码/文档/diff 门禁通过。
@@ -8940,6 +8940,31 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。
## 2026-09-18 GPT Image 2.5 业务模型与具体 provider 定价路由
- **决策**:新任务使用业务模型值 `gpt-image-2.5`api-server 按任务显式 dispatch 具体模型 `gpt-image-2.5-flare-c`(生成)或 `gpt-image-2.5-sunburst-c`(编辑),并把同一具体 key 交给 `platform-image` 与后台定价解析。普通生成即使因参考图使用 edits multipart,仍按生成 route;同模型重试不跨模型 fallback。
- **历史兼容**:已持久化 `gpt-image-2` 读回原值不改写;基于旧资源发起新任务时,在提交边界解析为 `gpt-image-2.5`,新任务/新产物按新业务值和当前 task price 处理。旧 `gpt-image-2-c` 仅保留历史审计,不再作为 fallback 或业务模型。
- **可见性**:普通主站前端和公开定价 API 不接收具体 provider key;新生成 UI label 为 `GPT Image 2.5`,历史资源/旧编辑上下文不扩散该 label。admin Web/API 是明确例外,可查看和编辑两个具体定价 key。旧单 key 定价配置允许受控 backfill,并加 compatibility TODO。
- **关联 ADR**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)。
- **补充**GPT Image 2.5 的两个具体模型通过显式 `TIANTOKEN_BASE_URL` 与独立 `TIANTOKEN_API_KEY` 发送;环境变量缺失时必须失败,禁止使用 VectorEngine 配置或 API key 回退。
## 2026-09-18 GPT Image 2.5 provider-neutral 执行器与双 client 启动边界
- **决策**:图片协议执行逻辑保持单一共享实现;只抽出 provider client 的 identity、base URL、API key 和 client 构造,通过依赖注入复用请求 body、multipart、尺寸、retry、响应和 audit。
- **路由**`gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 走 Tiantoken`gemini-3.1-flash-image-preview`nanobanana)走 VectorEngine`gpt-image-2` / `gpt-image-2-c` 仅是历史字符串,新任务不再进入旧 GPT Image 2 路由;未知 model 拒绝。
- **启动**api-server 启动时同时构造 VectorEngine 与 Tiantoken 两个 required client;各自只读取自己的环境变量,任一配置缺失即启动失败,不延迟到首次请求。
- **重试**:只在同一个 concrete model 内 retry,禁止跨 model、跨 provider fallback。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/project-memory/plans/【里程碑】GPT Image 2.5 provider边界重构-2026-09-18.md`](../plans/【里程碑】GPT%20Image%202.5%20provider边界重构-2026-09-18.md)。
## 2026-09-19 Tiantoken 凭据落到 AppConfig 字段并修复编辑器 LLM 测试夹具
- **决策**Tiantoken 凭据仍然只从 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 读取,不回退 VectorEngine;读取点从 `AppState::new` 前移到 `AppConfig::from_env()`,落到 `AppConfig.tiantoken_base_url` / `AppConfig.tiantoken_api_key` 两个字段,与其它 provider 的配置形态一致,`AppState` 构造与测试构造复用同一条路径;删除 `config::tiantoken_base_url()` / `config::tiantoken_api_key()` 两个现读环境变量的访问器。
- **原因**2026-09-18 的「双 provider 启动边界」改成现读环境变量后,`editor_background_music_prompt_assist``editor_sound_effect_prompt_assist``vector_engine_audio_generation/sound_effect_translation` 三处 mock LLM 夹具仍用 `AppConfig.vector_engine_*` 构造状态,28 条用例拿到 503 `editor_llm_unavailable`。改用进程环境变量做夹具会让并行用例互相踩 `TIANTOKEN_*`,因此把凭据落到 config 字段而不是在测试里 set_var。
- **边界**ADR 的「不得回退 VectorEngine 或其 API key」仍然成立,`from_env` 只读 `TIANTOKEN_*`,并有用例固定该行为。
- **验证**`cargo test -p api-server` 1060 passed / 0 failed / 6 ignored`cargo fmt --all --check``npm run check:encoding``git diff --check` 通过。
- **关联文档**[`docs/adr/【ADR】GPT Image 2.5模型路由与历史值兼容-2026-09-18.md`](../../adr/【ADR】GPT%20Image%202.5模型路由与历史值兼容-2026-09-18.md)、[`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。
## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环 ## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环
- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifestBgFilter 已成功处理但 Agent 因此永远只能看到受理回执。 - 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifestBgFilter 已成功处理但 Agent 因此永远只能看到受理回执。

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