Compare commits

..

70 Commits

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

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

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

resolve_image_provider 白名单不再接受该值

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

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

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

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

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

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

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

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

保留原有请求与错误语义
2026-09-21 20:37:21 +08:00
lhk229 a6f99c8871 Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m32s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m27s
Project CI / Backend tests (pull_request) Successful in 5m36s
Project CI / Native shell tests (pull_request) Successful in 8m0s
Project CI / Frontend tests (pull_request) Failing after 4m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m55s
Project CI / Repository checks (pull_request) Successful in 4m7s
2026-09-21 19:47:37 +08:00
k88936 27e3d9bfd0 Merge remote-tracking branch 'origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m18s
Project CI / Backend tests (pull_request) Successful in 8m6s
Project CI / Native shell tests (pull_request) Successful in 9m16s
Project CI / Frontend tests (pull_request) Failing after 7m0s
Project CI / Repository checks (pull_request) Successful in 7m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m16s
2026-09-21 14:43:20 +08:00
k88936 c6f90c5b22 Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
Project CI / Backend tests (pull_request) Successful in 7m13s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 9m35s
Project CI / Repository checks (pull_request) Successful in 5m57s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m48s
2026-09-21 13:39:16 +08:00
k88936 3b137ca369 合并 master 并保留 GPT Image 2.5 与外置提示词行为
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m1s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
Project CI / Native shell tests (pull_request) Successful in 9m25s
Project CI / Backend tests (pull_request) Successful in 10m37s
Project CI / Repository checks (pull_request) Successful in 7m52s
Project CI / Frontend tests (pull_request) Failing after 10m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m25s
保留编辑器图片工具使用 gpt-image-2.5 业务模型名

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

nanobanana 路径改用 VectorEngine provider

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

保留 nanobanana 普通生成默认行为

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

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

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

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

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

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

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

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

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

引入 ImageProviderClient 与 VectorEngine/Tiantoken provider 类型

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

记录 Tiantoken/VectorEngine 双 client 启动约束

同步开发运维文档中的图片路由与环境变量要求
2026-09-18 16:01:32 +08:00
149 changed files with 2868 additions and 11141 deletions
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` is a legacy value resolved only when submitting a new task; the retired `gpt-image-2-c` is no longer accepted and is handled as an unsupported value.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
+22 -14
View File
@@ -1,11 +1,17 @@
---
name: gpt-image-2-apimart
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
description: Generate or inspect project image assets through this repository's Tiantoken GPT Image 2.5 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
---
# gpt-image-2 VectorEngine
# GPT Image 2.5 project image workflow
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
Use this skill for project-local image asset generation that must match the repository's image request contract. Provider routing is owned by `server-rs`; when this skill talks to the provider directly it must send the concrete provider model and the matching provider credentials, because the provider side only accepts concrete models:
- generation (no reference images): `gpt-image-2.5-flare-c` through Tiantoken
- edits (any reference image): `gpt-image-2.5-sunburst-c` through Tiantoken
- nanobanana (`gemini-3.1-flash-image-preview`) stays on VectorEngine
The business model name `gpt-image-2.5` is resolved to a concrete key by `server-rs` at the task boundary and is never sent to a provider directly. This client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
## Workflow
@@ -24,15 +30,15 @@ Use this skill for project-local image asset generation that must match the repo
```
5. Save final project assets under `public/` or another explicitly requested workspace path.
6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists.
6. Never print `TIANTOKEN_API_KEY`. Report only whether configuration exists.
## Request Contract
The repository image path uses:
```text
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
POST {TIANTOKEN_BASE_URL}/v1/images/generations
Authorization: Bearer {TIANTOKEN_API_KEY}
Content-Type: application/json
```
@@ -40,7 +46,7 @@ Default body:
```json
{
"model": "gpt-image-2",
"model": "gpt-image-2.5-flare-c",
"prompt": "<prompt>",
"n": 1,
"size": "1024x1024"
@@ -50,22 +56,22 @@ Default body:
For visual references, use the edit endpoint instead of the create endpoint:
```text
POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
POST {TIANTOKEN_BASE_URL}/v1/images/edits
Authorization: Bearer {TIANTOKEN_API_KEY}
Content-Type: multipart/form-data
```
Multipart fields:
```text
model=gpt-image-2
model=gpt-image-2.5-sunburst-c
prompt=<prompt>
n=1
size=1024x1024
image=@reference.png
```
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
In this repository, calls with no reference images use `POST /v1/images/generations` with `gpt-image-2.5-flare-c`; calls with any reference image use `POST /v1/images/edits` with `gpt-image-2.5-sunburst-c` and pass references as one or more `image` form parts. Server-side calls still submit the business model `gpt-image-2.5` and let `server-rs` resolve the concrete key; provider routing and retry policy remain server-owned, and pricing follows the same reference-image rule (generation with a reference image is charged at the edit tier). Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
@@ -75,12 +81,14 @@ Load environment values from process env first, then `.env.secrets.local`, `.env
Required for live generation:
- `VECTOR_ENGINE_BASE_URL`
- `VECTOR_ENGINE_API_KEY`
- `TIANTOKEN_BASE_URL`
- `TIANTOKEN_API_KEY`
Optional:
- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- `TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS`
`VECTOR_ENGINE_*` values no longer serve GPT Image 2.5: those credentials belong to VectorEngine, which only serves nanobanana. Do not fall back from `TIANTOKEN_*` to `VECTOR_ENGINE_*`.
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
@@ -9,8 +9,9 @@ const skillRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
const preferredImageModel = 'gpt-image-2.5-flare-c';
const prompts = [
{
@@ -101,18 +102,18 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
function buildTiantokenImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
@@ -233,22 +234,22 @@ async function fetchJson(url, options, timeoutMs) {
const text = await response.text();
if (!response.ok) {
const error = new Error(
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
);
error.vectorEngineStatus = response.status;
error.vectorEngineBody = text;
error.providerStatus = response.status;
error.providerBody = text;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
error.providerResponseParse = true;
error.providerBody = text;
throw error;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -256,81 +257,35 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, entry) {
for (const model of [preferredImageModel, fallbackImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
};
try {
const payload = await fetchJson(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(
extractBase64Images(payload)[0],
);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
const error = new Error(`VectorEngine returned no image for ${entry.id}`);
error.vectorEngineResponseParse = true;
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
}
const model = preferredImageModel;
const requestBody = {
model,
prompt: buildPrompt(entry),
n: 1,
size: '1024x1024',
};
const payload = await fetchJson(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${entry.id}`);
const error = new Error(`Tiantoken returned no image for ${entry.id}`);
error.providerResponseParse = true;
error.providerBody = JSON.stringify(payload).slice(0, 600);
throw error;
}
async function downloadUrl(url, timeoutMs) {
@@ -373,7 +328,7 @@ async function generateOne(env, entry, outDir) {
const bytes = decodeStrictBase64Image(b64Images[0]);
if (!bytes) {
throw new Error(
`VectorEngine returned invalid base64 image for ${entry.id}`,
`Tiantoken returned invalid base64 image for ${entry.id}`,
);
}
image = {
@@ -381,7 +336,7 @@ async function generateOne(env, entry, outDir) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${entry.id}`);
throw new Error(`Tiantoken returned no image for ${entry.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -408,7 +363,6 @@ if (dryRun) {
requests: selectedPrompts.map((entry) => ({
id: entry.id,
title: entry.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(entry),
@@ -429,7 +383,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -18,8 +18,9 @@ const defaultOutDir = path.join(
'puzzle-creation-templates',
);
const defaultTimeoutMs = 1000000;
const preferredImageModel = 'gpt-image-2';
const fallbackImageModel = 'gpt-image-2-c';
// GPT Image 2.5 生成任务只接受 concrete provider model;业务模型名 `gpt-image-2.5`
// 由 server-rs 在任务边界解析,脚本直连 provider 时必须自己给出 concrete key。
const preferredImageModel = 'gpt-image-2.5-flare-c';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
@@ -71,18 +72,18 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
function buildTiantokenImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
@@ -203,22 +204,22 @@ async function fetchJson(url, options, timeoutMs) {
const text = await response.text();
if (!response.ok) {
const error = new Error(
`VectorEngine ${response.status}: ${text.slice(0, 600)}`,
`Tiantoken ${response.status}: ${text.slice(0, 600)}`,
);
error.vectorEngineStatus = response.status;
error.vectorEngineBody = text;
error.providerStatus = response.status;
error.providerBody = text;
throw error;
}
try {
return JSON.parse(text);
} catch (error) {
error.vectorEngineResponseParse = true;
error.vectorEngineBody = text;
error.providerResponseParse = true;
error.providerBody = text;
throw error;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -226,83 +227,35 @@ async function fetchJson(url, options, timeoutMs) {
}
}
function shouldFallbackImageModel(error) {
const raw =
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
if (error?.vectorEngineResponseParse) {
return !containsContentRejection(raw);
}
const status = Number(error?.vectorEngineStatus || 0);
if (status === 408 || status >= 500) {
return true;
}
if (status === 429) {
return !containsContentRejection(raw);
}
const mentionsImageModel =
raw.includes('model') ||
raw.includes('模型') ||
raw.includes(preferredImageModel) ||
raw.includes(fallbackImageModel);
return (
[400, 404, 422].includes(status) &&
mentionsImageModel &&
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
raw,
)
);
}
function containsContentRejection(raw) {
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
raw,
);
}
async function requestImagePayload(env, template) {
for (const model of [preferredImageModel, fallbackImageModel]) {
const requestBody = {
model,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
};
try {
const payload = await fetchJson(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(
extractBase64Images(payload)[0],
);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
const error = new Error(
`VectorEngine returned no image for ${template.id}`,
);
error.vectorEngineResponseParse = true;
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
throw error;
} catch (error) {
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
throw error;
}
console.warn(
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
);
}
const model = preferredImageModel;
const requestBody = {
model,
prompt: buildPrompt(template),
n: 1,
size: '1024x1024',
};
const payload = await fetchJson(
buildTiantokenImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const base64Image = decodeStrictBase64Image(extractBase64Images(payload)[0]);
if (extractImageUrls(payload)[0] || base64Image) {
return payload;
}
throw new Error(`VectorEngine returned no image for ${template.id}`);
const error = new Error(`Tiantoken returned no image for ${template.id}`);
error.providerResponseParse = true;
error.providerBody = JSON.stringify(payload).slice(0, 600);
throw error;
}
async function downloadUrl(url, timeoutMs) {
@@ -345,7 +298,7 @@ async function generateOne(env, template, outDir) {
const bytes = decodeStrictBase64Image(b64Images[0]);
if (!bytes) {
throw new Error(
`VectorEngine returned invalid base64 image for ${template.id}`,
`Tiantoken returned invalid base64 image for ${template.id}`,
);
}
image = {
@@ -353,7 +306,7 @@ async function generateOne(env, template, outDir) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${template.id}`);
throw new Error(`Tiantoken returned no image for ${template.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -384,7 +337,6 @@ if (dryRun) {
requests: selectedTemplates.map((template) => ({
id: template.id,
title: template.title,
fallbackModel: fallbackImageModel,
body: {
model: preferredImageModel,
prompt: buildPrompt(template),
@@ -405,7 +357,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
+29
View File
@@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
**图片模型历史值与使用端解析**:
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
**图片 provider 显式路由**:
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
**业务模型**:
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
**具体模型**:
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c``gpt-image-2.5-sunburst-c`nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
**provider client**:
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API keyVectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
**历史模型值**:
已持久化的 `gpt-image-2` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。已退役的 `gpt-image-2-c` 已从代码整体删除,只有数据库里的历史审计字符串原样保留,任何入口传入该值都按不支持的值处理。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端、为已删除的 `gpt-image-2-c` 重新加回常量或解析分支
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -81,12 +81,14 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
},
],
};
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
const fetchMock = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
args: [
'cp',
'--force',
'/tmp/a.json',
'oss://agc-dev/agc/global-version.json',
],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',
@@ -1192,7 +1192,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
File diff suppressed because it is too large Load Diff
@@ -93,17 +93,10 @@ pub(super) struct ExecutionLedger {
pub(super) plan: Option<Value>,
#[serde(default)]
pub(super) last_failed_write_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
}
struct SessionData {
ledger: ExecutionLedger,
analytics_capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_output_revision: Option<u64>,
started: Instant,
initial_elapsed_ms: u64,
lease_started: BTreeMap<String, Instant>,
@@ -160,8 +153,6 @@ fn executor_digest(path: &Path) -> Result<String, String> {
pub(super) struct ExecutionSession {
pub(super) root: PathBuf,
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
pub(super) newly_accepted: bool,
state_path: PathBuf,
_owner: File,
data: Mutex<SessionData>,
@@ -225,16 +216,6 @@ pub(crate) struct WritePermit {
id: String,
}
impl WritePermit {
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
self.session
.record_analytics_revision(revision, change_kind, files_changed_count);
}
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let mut data = self.session.lock()?;
self.session.tick_locked(&mut data)?;
@@ -420,7 +401,6 @@ pub(super) async fn begin(
prompt: &str,
requires_contract: bool,
config: DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<ExecutionSessionGuard, String> {
let root = root.to_path_buf();
let prompt_hash = hash(prompt.as_bytes());
@@ -428,14 +408,13 @@ pub(super) async fn begin(
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
let host = crate::game_creator_runtime_config_dir()
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
open_with_analytics_at(
open_at(
&host.join("direct-executions"),
&root,
&turn,
&prompt_hash,
requires_contract,
&config,
analytics_run,
)
})
.await
@@ -483,26 +462,6 @@ pub(super) fn open_at(
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
) -> Result<Arc<ExecutionSession>, String> {
open_with_analytics_at(
host,
root,
turn,
request_hash,
requires_contract,
config,
None,
)
}
pub(super) fn open_with_analytics_at(
host: &Path,
root: &Path,
turn: &str,
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<Arc<ExecutionSession>, String> {
config.validate()?;
let root = root
@@ -562,7 +521,6 @@ pub(super) fn open_with_analytics_at(
};
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
let is_new = existing.is_none();
let mut newly_accepted = is_new;
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
schema_version: SCHEMA.into(),
client_turn_id: turn.into(),
@@ -588,7 +546,6 @@ pub(super) fn open_with_analytics_at(
delivery_reviews: 0,
plan: None,
last_failed_write_revision: None,
analytics_run,
});
if is_new {
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
@@ -601,8 +558,6 @@ pub(super) fn open_with_analytics_at(
512 * 1024,
)?;
if let Some(legacy) = legacy {
newly_accepted = false;
ledger.analytics_run = None;
let used = legacy["usedRuns"]
.as_u64()
.and_then(|n| u32::try_from(n).ok());
@@ -659,13 +614,10 @@ pub(super) fn open_with_analytics_at(
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
let session = Arc::new(ExecutionSession {
root,
newly_accepted,
state_path,
_owner: owner,
data: Mutex::new(SessionData {
ledger,
analytics_capture: None,
analytics_output_revision: None,
started: Instant::now(),
initial_elapsed_ms,
lease_started: BTreeMap::new(),
@@ -786,10 +738,6 @@ impl ExecutionSession {
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.cancellation)
}
pub(super) fn was_aborted(&self) -> bool {
self.abort_requested
.load(std::sync::atomic::Ordering::Acquire)
}
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
let mut data = self.lock()?;
if data.ledger.phase.is_terminal() {
@@ -817,74 +765,6 @@ impl ExecutionSession {
self.commit(&mut data, next)?;
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
}
pub(super) fn set_analytics_capture(
&self,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) {
let Ok(mut data) = self.data.try_lock() else {
return;
};
data.analytics_capture = capture.and_then(|(mut context, writer)| {
// 恢复或账号切换后仍归属于真实受理的原 run。
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
Some((context, writer))
});
}
pub(super) fn analytics_capture(
&self,
) -> Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)> {
self.data.try_lock().ok()?.analytics_capture.clone()
}
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
if files_changed_count == 0 {
return;
}
let Ok(mut data) = self.data.try_lock() else {
return;
};
if data.ledger.analytics_run.is_none() {
return;
}
data.analytics_output_revision =
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
let capture = data.analytics_capture.clone();
let project_id = data.ledger.project_id.clone();
drop(data);
crate::analytics::project::revision(
capture,
&project_id,
Source::Direct,
RevisionCreated {
revision_id: revision.to_string(),
revision_source: RevisionSource::Agent,
change_kind,
files_changed_count: Some(files_changed_count),
},
);
}
pub(super) fn analytics_output_revision(&self) -> Option<String> {
self.data
.try_lock()
.ok()?
.analytics_output_revision
.map(|revision| revision.to_string())
}
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
let data = self.lock()?;
let mut state = data.ledger.clone();
@@ -1,74 +1,5 @@
use super::*;
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
use crate::analytics::contract::{Context, Route, RunSource, Source};
crate::analytics::run::Metadata::new(
Context {
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
},
Source::Direct,
RunSource::UserSubmit,
)
}
#[test]
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
let original = analytics_metadata("A");
let host = temp.path().join("host");
let session = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(original.clone()),
)
.unwrap();
assert!(session.newly_accepted);
assert_eq!(
session.snapshot().unwrap().analytics_run,
Some(original.clone())
);
drop(session);
let replay = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(!replay.newly_accepted);
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
}
#[test]
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
let (temp, session) = fixture(Default::default());
let root = session.root.clone();
assert!(session.snapshot().unwrap().analytics_run.is_none());
drop(session);
let replay = open_with_analytics_at(
&temp.path().join("host"),
&root,
"turn-test",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(replay.snapshot().unwrap().analytics_run.is_none());
}
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -82,7 +13,6 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
&config,
)
.unwrap();
assert!(session.newly_accepted);
session
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
.unwrap();
@@ -229,7 +159,6 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
)
.unwrap();
let state = reopened.snapshot().unwrap();
assert!(!reopened.newly_accepted);
assert_eq!(state.delivery_reviews, 1);
assert_eq!(
(
@@ -349,7 +278,6 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
&Default::default(),
)
.unwrap();
assert!(!session.newly_accepted);
assert!(session.admit(EffectKind::Execute, None).is_err());
let state = session.snapshot().unwrap();
assert_eq!(state.used_passes, 2);
@@ -229,40 +229,6 @@ fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<Strin
.collect()
}
fn analytics_patch_changes(
before: &BTreeMap<String, Option<String>>,
after: &BTreeMap<String, Option<String>>,
) -> Option<(crate::analytics::contract::ChangeKind, u64)> {
use crate::analytics::contract::ChangeKind;
let mut result = None;
for (path, before) in before {
let Some((before, after)) = before
.as_ref()
.zip(after.get(path).and_then(Option::as_ref))
else {
continue;
};
if before == after {
continue;
}
let Some(kind) = crate::analytics::project::file_change_kind(path) else {
continue;
};
result = Some(match result {
None => (kind, 1),
Some((current, count)) => (
if current == kind {
current
} else {
ChangeKind::Mixed
},
count + 1,
),
});
}
result
}
fn run_transaction(
root: &Path,
parsed: codex_patch_parser::ApplyPatchArgs,
@@ -353,13 +319,6 @@ fn run_transaction(
None
};
lease.finish(passed && !uncertain, changed, None)?;
if passed && !uncertain {
if let (Some(revision), Some((kind, count))) =
(revision, analytics_patch_changes(&before, &after))
{
session.record_analytics_revision(revision, kind, count);
}
}
Ok(json!({
"status": if passed && !uncertain { "completed" } else { "failed" },
"changedPaths": if started { changed_paths } else { BTreeSet::new() },
@@ -392,47 +351,6 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result<Value, Strin
mod tests {
use super::*;
#[test]
fn analytics_patch_counts_only_known_changed_outputs() {
let fingerprints = |entries: &[(&str, Option<&str>)]| {
entries
.iter()
.map(|(path, value)| (path.to_string(), value.map(str::to_string)))
.collect()
};
let before = fingerprints(&[
("game/a.js", Some("a")),
("game/same.js", Some("same")),
("game/unknown.js", None),
("game/unreadable.js", Some("old")),
(".agent/state.json", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("b")),
("game/same.js", Some("same")),
("game/unknown.js", Some("new")),
("game/unreadable.js", None),
(".agent/state.json", Some("new")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Code, 1))
);
assert_eq!(analytics_patch_changes(&before, &before), None);
let before = fingerprints(&[
("game/a.js", Some("missing")),
("assets/a.png", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("new")),
("assets/a.png", Some("missing")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Mixed, 2))
);
}
fn project() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -497,20 +415,15 @@ mod tests {
#[tokio::test]
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
let (temp, root) = project();
let config = temp.path().join("analytics-config");
let (metadata, context, writer) =
super::super::direct_tool_bridge::analytics_test_writer(&config);
let session = direct_execution::open_with_analytics_at(
let session = direct_execution::open_at(
&temp.path().join("host"),
&root,
"patch-roundtrip",
&format!("{:x}", Sha256::digest(b"request")),
false,
&direct_validation::DirectValidationConfig::default(),
Some(metadata),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
session
.freeze_contract(json!({"fixture":"patch protocol only"}))
.unwrap();
@@ -586,29 +499,6 @@ mod tests {
"writes do not invent execution passes"
);
assert!(session.snapshot().unwrap().active.is_empty());
assert_eq!(
session.analytics_output_revision(),
None,
"text fixture files are not classified as成果"
);
let outputs = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/result.js\n+const result = 1;\n*** Add File: game/style.css\n+body { color: red; }\n*** End Patch"})).await.unwrap();
assert_eq!(outputs["status"], "completed", "{outputs}");
let revision = outputs["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let partial_output = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/partial.js\n+const partial = 1;\n*** Update File: game/absent.js\n@@\n-old\n+new\n*** End Patch"})).await.unwrap();
assert_eq!(partial_output["status"], "failed");
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let events = super::super::direct_tool_bridge::drain_analytics_test_writer(
&config, &context, &writer,
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
assert_eq!(revisions[0]["properties"]["files_changed_count"], 2);
session.interrupt("fixture stopped".into()).unwrap();
assert!(apply(
&root,
@@ -4243,44 +4243,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BrowserValidationResult, String> {
run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
advisory_interaction,
cancellation,
None,
)
.await
}
pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
root: &Path,
evidence_root: PathBuf,
scenario: Option<crate::browser::BrowserPlaytestScenario>,
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) -> Result<BrowserValidationResult, String> {
let observation = crate::analytics::preview::prepare(
root,
capture,
crate::analytics::contract::Source::Direct,
crate::analytics::contract::PreviewSource::Agent,
);
let (_analytics_lease, observation) = match observation {
Some((lease, observation)) => (Some(lease), Some(observation)),
None => (None, None),
};
let (preview, stop_sender) = start_local_game_preview_for_project(root)?;
if let Some(observation) = observation {
observation
.with_cancellation(cancellation.clone())
.schedule(preview.port);
}
let validation = crate::browser::validate_local_preview_in_browser_with_cancellation(
BrowserValidationInput {
url: preview.url,
@@ -4298,7 +4261,6 @@ pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
cancellation,
)
.await;
drop(_analytics_lease);
let _ = stop_sender.send(());
validation
}
@@ -4819,8 +4781,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
None,
None,
None,
None,
None,
)
.await
}
@@ -4832,11 +4792,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -4859,8 +4814,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter,
audit,
direct_user_item,
capture,
analytics_attempt_id,
)
.await
{
@@ -5061,11 +5014,6 @@ async fn run_direct_game_creator_turn_inner(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, DirectCodexTurnFailure> {
let requires_contract = super::direct_delivery::requires_new_web_contract(
root,
@@ -5087,49 +5035,16 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?
.validation;
let analytics_run = capture.as_ref().map(|(context, _)| {
crate::analytics::run::Metadata::new(
context.clone(),
crate::analytics::contract::Source::Direct,
crate::analytics::contract::RunSource::UserSubmit,
)
});
let execution_guard = super::direct_execution::begin(
root,
prompt,
requires_contract,
execution_config,
analytics_run,
)
.await
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
let execution_guard =
super::direct_execution::begin(root, prompt, requires_contract, execution_config)
.await
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let execution_session = execution_guard.session();
execution_session.set_analytics_capture(capture.clone());
let started = execution_session
.newly_accepted
.then(std::time::Instant::now);
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) {
crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata);
}
}
}
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, DirectCodexTurnFailure> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
return Ok(report);
}
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
crate::analytics::goal::accepted(
capture.clone(),
root,
&ledger.project_id,
crate::analytics::contract::Source::Direct,
);
}
}
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("preparing"), None, None);
@@ -5419,118 +5334,6 @@ async fn run_direct_game_creator_turn_inner(
})?;
}
Ok(visible_reply)
}.await;
if let Ok(ledger) = execution_session.snapshot() {
if let (Some(metadata), Some((end_reason, error_code))) = (
&ledger.analytics_run,
direct_analytics_outcome(
ledger.phase,
ledger.requires_contract || ledger.contract.is_some(),
result.is_err(),
execution_session.was_aborted(),
),
) {
let output_revision = execution_session.analytics_output_revision();
crate::analytics::run::direct_finished(
capture,
root,
&ledger.project_id,
metadata,
analytics_attempt_id,
crate::analytics::run::Outcome {
turn_id: Some(ledger.client_turn_id.clone()),
end_reason,
error_code,
duration_ms: started
.and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()),
output_change_detected: output_revision.as_ref().map(|_| true),
revision_id: output_revision,
},
);
}
}
result
}
fn direct_analytics_outcome(
phase: super::direct_execution::ExecutionPhase,
has_contract: bool,
failed: bool,
aborted: bool,
) -> Option<(
crate::analytics::contract::RunEndReason,
Option<crate::analytics::contract::ErrorCode>,
)> {
use super::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
if phase == ExecutionPhase::Interrupted || aborted {
return None;
}
if phase == ExecutionPhase::Exhausted {
return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)));
}
if failed {
// Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。
return Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified),
));
}
if phase == ExecutionPhase::Completed || !has_contract {
return Some((RunEndReason::Finished, None));
}
None
}
#[cfg(test)]
mod direct_analytics_tests {
use super::*;
use crate::agent::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
#[test]
fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false),
Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)))
);
for failed in [true, false] {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false),
None
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, failed, true),
None
);
}
}
#[test]
fn complete_delivery_does_not_hide_later_projection_failure() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, true, false),
Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified)
))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, false, false),
Some((RunEndReason::Finished, None))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, false, false),
Some((RunEndReason::Finished, None))
);
for phase in [
ExecutionPhase::Working,
ExecutionPhase::Draining,
ExecutionPhase::Sealing,
] {
assert_eq!(direct_analytics_outcome(phase, true, false, false), None);
}
}
}
#[cfg(test)]
@@ -34,10 +34,8 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
mut user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
@@ -101,8 +99,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
Some(&turn_emitter),
Some(&mut audit),
canonical_user_item,
capture,
analytics_attempt_id.as_deref(),
)
.await
{
@@ -1600,15 +1600,6 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
bridge_write_file_with_permit(root, arguments, None)
}
fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option<bool> {
crate::analytics::project::file_content_changed(
root,
path,
content,
DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64,
)
}
fn bridge_write_file_with_permit(
root: &Path,
arguments: &Value,
@@ -1650,11 +1641,6 @@ fn bridge_write_file_with_permit(
"direct-codex.file.write",
)?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let analytics_change_kind = write_permit.and_then(|_| {
let kind = crate::analytics::project::file_change_kind(&path)?;
(bridge_file_content_changed(root, &path, content.as_bytes()) == Some(true))
.then_some(kind)
});
let write_started = std::time::Instant::now();
let commit = || {
let written = write_local_project_file_at(root, &path, content)?;
@@ -1667,9 +1653,6 @@ fn bridge_write_file_with_permit(
Some(permit) => permit.run(commit)?,
None => commit()?,
};
if let (Some(permit), Some(kind)) = (write_permit, analytics_change_kind) {
permit.record_analytics_revision(revision, kind, 1);
}
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
@@ -3490,82 +3473,6 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test(
.await
}
#[cfg(test)]
pub(super) fn analytics_test_writer(
config: &Path,
) -> (
crate::analytics::run::Metadata,
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
) {
use crate::analytics::{
contract::{Context, Route, RunSource, Source},
run,
store::AnalyticsWriter,
};
let mut context = Context {
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
};
fs::create_dir_all(config).expect("create analytics test config directory");
let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit);
context.route.user_id = Some("B".into());
let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone());
(metadata, context, writer)
}
#[cfg(test)]
pub(super) fn drain_analytics_test_writer(
config: &Path,
context: &crate::analytics::contract::Context,
writer: &crate::analytics::store::AnalyticsWriter,
) -> Vec<Value> {
use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source};
let marker = context
.capture(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
None,
)
.unwrap();
let marker_id = marker.event_id.clone();
assert!(writer.try_record(context.route.clone(), marker, marker_id.clone()));
assert!(writer.flush());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let events: Vec<Value> = fs::read_dir(&batches)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok())
.flat_map(|contents| {
contents
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect::<Vec<_>>()
})
.collect();
if events.iter().any(|event| event["event_id"] == marker_id) {
return events;
}
assert!(
std::time::Instant::now() < deadline,
"analytics FIFO sentinel timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
@@ -4355,293 +4262,6 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
}
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
contract::{ErrorCode, RunEndReason},
run,
};
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().join("project");
let config = temporary.path().join("config");
let (metadata, context, writer) = analytics_test_writer(&config);
let original_capture = Some((metadata.context.clone(), writer.clone()));
let mut lifecycle = crate::analytics::gui::LifecycleFixture::start(
metadata.context.clone(),
writer.clone(),
);
lifecycle.create_and_open(&root, "direct-analytics");
let session = super::super::direct_execution::open_with_analytics_at(
&temporary.path().join("host"),
&root,
"analytics-write",
&format!("{:x}", Sha256::digest(b"request")),
false,
&Default::default(),
Some(metadata.clone()),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
super::super::direct_delivery::register_contract(
&root,
&session,
&json!({
"scope": "核对当前项目宿主写入的成果采集",
"changeKind": "project",
"requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}]
}),
)
.await
.expect("freeze a validated delivery contract before writing");
let project_id = session.snapshot().unwrap().project_id;
run::accepted(&writer, &root, &project_id, &metadata);
crate::analytics::goal::accepted(
original_capture.clone(),
&root,
&project_id,
crate::analytics::contract::Source::Direct,
);
let lease = session
.admit(super::super::direct_execution::EffectKind::Write, None)
.unwrap();
let permit = lease.write_permit().unwrap();
let arguments = json!({"path":"game/index.html", "content":"<!doctype html><html><body>真实预览</body></html>"});
let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit));
assert_eq!(changed["isError"], false);
let payload: Value =
serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap();
let revision = payload["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
assert_eq!(
bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"],
false
);
assert_eq!(
bridge_write_file_with_permit(
&root,
&json!({"path":"../bad.js","content":"bad"}),
Some(&permit)
)["isError"],
true
);
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
lease.finish(true, true, None).unwrap();
let attempt = uuid::Uuid::new_v4().to_string();
run::direct_finished(
Some((context.clone(), writer.clone())),
&root,
&project_id,
&metadata,
Some(&attempt),
run::Outcome {
turn_id: Some("analytics-write".into()),
end_reason: RunEndReason::Failed,
error_code: Some(ErrorCode::RuntimeErrorUnclassified),
duration_ms: None,
output_change_detected: Some(true),
revision_id: session.analytics_output_revision(),
},
);
run::settle(Some((context.clone(), writer.clone())), &attempt, false);
// 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。
let served_root = crate::project_game_root(&root);
fs::create_dir_all(&served_root).unwrap();
fs::write(
served_root.join("index.html"),
"<!doctype html><html><body>真实预览构建</body></html>",
)
.unwrap();
let (preview_lease, observation) = crate::analytics::preview::prepare(
&root,
original_capture.clone(),
crate::analytics::contract::Source::Editor,
crate::analytics::contract::PreviewSource::User,
)
.unwrap();
let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap();
observation.observe(preview.port).await;
let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root)
.unwrap()
.revision
.to_string();
drop(preview_lease);
let _ = stop.send(());
let checkpoint = crate::commands::checkpoint_with_capture_for_test(
root.to_string_lossy().into_owned(),
original_capture,
)
.unwrap();
lifecycle.exit();
let events = drain_analytics_test_writer(&config, &context, &writer);
let ids: std::collections::HashSet<_> = events
.iter()
.map(|event| event["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), events.len());
let chain: Vec<_> = events
.iter()
.filter(|event| event["user_id"] == "A")
.collect();
for name in [
"editor_session_start",
"editor_focus_start",
"project_create_success",
"project_open",
"creative_task_submit",
"project_revision_created",
"agent_run_failed",
"preview_ready",
"project_save",
"editor_focus_end",
"editor_session_end",
] {
assert_eq!(
chain
.iter()
.filter(|event| event["event_name"] == name)
.count(),
1,
"{name}"
);
}
for event in &chain {
assert_eq!(
event["editor_session_id"],
metadata.context.editor_session_id
);
if !event["project_id"].is_null() {
assert_eq!(event["project_id"], project_id);
}
if !event["creative_task_id"].is_null() {
assert_eq!(event["creative_task_id"], project_id);
}
if !event["agent_run_id"].is_null() {
assert_eq!(event["agent_run_id"], metadata.run_id);
}
let name = event["event_name"].as_str().unwrap();
if matches!(
name,
"project_create_success"
| "project_open"
| "creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["project_id"], project_id,
"{name} must identify its project"
);
}
if matches!(
name,
"creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["creative_task_id"], project_id,
"{name} must identify its goal"
);
}
if name == "agent_run_failed" {
assert_eq!(event["agent_run_id"], metadata.run_id);
assert_eq!(event["agent_turn_id"], "analytics-write");
}
}
let save = chain
.iter()
.find(|event| event["event_name"] == "project_save")
.unwrap();
assert_eq!(save["properties"]["save_source"], "checkpoint");
assert!(Path::new(&checkpoint.checkpoint_path).is_dir());
let mut digest = Sha256::new();
digest.update(serde_json::to_vec(&metadata.context.route).unwrap());
digest.update([0]);
digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes());
let checkpoint_fact = format!("{:x}", digest.finalize());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let checkpoint_fact_matches = fs::read_dir(batches)
.unwrap()
.flatten()
.filter_map(|entry| fs::read(entry.path().join("meta.json")).ok())
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"])
.count();
assert_eq!(
checkpoint_fact_matches, 1,
"save fact must use the actual checkpoint ID"
);
let ready = chain
.iter()
.find(|event| event["event_name"] == "preview_ready")
.unwrap();
assert_eq!(ready["properties"]["preview_version"], preview_revision);
let focus_start = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_start")
.unwrap();
let focus_end = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_end")
.unwrap();
assert_eq!(
focus_start["properties"]["focus_interval_id"],
focus_end["properties"]["focus_interval_id"]
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
let failed: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "agent_run_failed")
.collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0]["user_id"], "A");
assert_eq!(failed[0]["properties"]["revision_id"], revision);
assert_eq!(failed[0]["properties"]["output_change_detected"], true);
}
#[test]
fn analytics_file_comparison_requires_known_bounded_content() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(true)
);
fs::write(root.join("new.js"), b"new").unwrap();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(false)
);
assert_eq!(
bridge_file_content_changed(root, "new.js", b"changed"),
Some(true)
);
fs::create_dir(root.join("directory.js")).unwrap();
assert_eq!(
bridge_file_content_changed(root, "directory.js", b"new"),
None
);
fs::write(
root.join("large.js"),
vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1],
)
.unwrap();
assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None);
}
#[test]
fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() {
let temporary = tempfile::tempdir().expect("create direct write root");
@@ -453,15 +453,12 @@ async fn run_browser_with_budget(
sequence,
),
)?;
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_at(
root,
evidence_root,
scenario,
false,
reservation.as_ref().map(|r| r.session.cancel_flag()),
reservation
.as_ref()
.and_then(|r| r.session.analytics_capture()),
)
.await;
let (result, passed) = match evidence {
@@ -126,9 +126,6 @@ pub(crate) struct DesignTurn {
pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>,
/// 仅持久化埋点关联,旧回合缺失时不补历史执行。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) analytics: Option<crate::analytics::run::Metadata>,
}
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
File diff suppressed because it is too large Load Diff
@@ -1,277 +0,0 @@
use super::*;
use serde_json::json;
fn context() -> Context {
Context {
route: Route::from_identity(Some("123".into()), Some("https://example.com/api")),
editor_session_id: Uuid::new_v4().to_string(),
client_version: "0.1.67".into(),
}
}
fn run() -> RunFinished {
RunFinished {
agent_type: AgentType::GameAgent,
run_source: RunSource::UserSubmit,
duration_ms: None,
retry_index: 0,
output_change_detected: None,
revision_id: None,
end_reason: RunEndReason::Finished,
}
}
fn samples() -> Vec<Event> {
let mut failed = run();
failed.end_reason = RunEndReason::Failed;
let values = vec![
(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorSessionEnd(SessionEnd {
end_reason: SessionEndReason::UserExit,
session_duration_ms: Some(10),
last_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusStart(FocusStart {
focus_interval_id: Uuid::new_v4().to_string(),
focus_reason: FocusReason::InitialFocus,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusEnd(FocusEnd {
focus_interval_id: Uuid::new_v4().to_string(),
blur_reason: BlurReason::WindowBlur,
focus_duration_ms: None,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::ProjectCreateSuccess(ProjectCreated {
creation_source: CreationSource::HomeGame,
project_template_id: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectOpen(ProjectOpened {
open_source: OpenSource::Recent,
is_first_open: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunCompleted(run()),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunFailed(failed),
Some("project".into()),
Source::Direct,
),
(
EventData::ProjectRevisionCreated(RevisionCreated {
revision_id: "42".into(),
revision_source: RevisionSource::UiEditor,
change_kind: ChangeKind::Ui,
files_changed_count: Some(1),
}),
Some("project".into()),
Source::UiEditor,
),
(
EventData::PreviewReady(PreviewReady {
preview_source: PreviewSource::User,
preview_version: "42".into(),
ready_duration_ms: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectSave(ProjectSaved {
save_source: SaveSource::Checkpoint,
revision_id: Some("42".into()),
}),
Some("project".into()),
Source::Manual,
),
];
values
.into_iter()
.map(|(data, project, source)| {
let identity = matches!(
data,
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
)
.then(|| RunIdentity {
run_id: Uuid::new_v4().to_string(),
turn_id: Some("real-turn".into()),
error_code: matches!(data, EventData::AgentRunFailed(_))
.then_some(ErrorCode::RuntimeErrorUnclassified),
});
context().capture(data, project, source, identity).unwrap()
})
.collect()
}
#[test]
fn all_events_round_trip_with_explicit_nullable_envelopes() {
let events = samples();
assert_eq!(events.len(), 12);
for event in events {
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value.as_object().unwrap().len(), 15);
let decoded: Event = serde_json::from_value(value).unwrap();
decoded.validate().unwrap();
assert_eq!(decoded.event_id, event.event_id);
assert_eq!(decoded.event_time, event.event_time);
}
}
#[test]
fn missing_top_level_fields_and_extra_fields_are_rejected() {
for event in samples() {
let value = serde_json::to_value(event).unwrap();
for field in value.as_object().unwrap().keys() {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<Event>(missing).is_err(),
"missing {field}"
);
}
let mut extra = value;
extra["prompt"] = json!("must not be accepted");
assert!(serde_json::from_value::<Event>(extra).is_err());
}
}
#[test]
fn event_properties_are_closed_and_nullable_fields_are_required() {
for event in samples() {
let mut extra = event.clone();
extra.properties["access_token"] = json!("never collected");
assert!(extra.validate().is_err());
for (field, value) in event.properties.as_object().unwrap() {
if !value.is_null() {
continue;
}
let mut missing = event.clone();
missing.properties.as_object_mut().unwrap().remove(field);
assert!(
missing.validate().is_err(),
"{} missing {field}",
event.event_name
);
}
}
let mut nested = samples().pop().unwrap();
nested.properties["pending_approval"] =
json!({"request_id":"a", "phase":"concept", "question":"private"});
assert!(nested.validate().is_err());
nested.properties["pending_approval"] = Value::Null;
nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]});
assert!(nested.validate().is_err());
}
#[test]
fn inconsistent_identity_status_and_numbers_are_rejected() {
let event = samples()
.into_iter()
.find(|e| e.event_name == "agent_run_completed")
.unwrap();
let mutate: Vec<(&str, Value)> = vec![
("creative_task_id", json!("other-project")),
("project_id", Value::Null),
("agent_run_id", json!("project")),
("status", json!("failed")),
("source", json!("design_agent")),
("error_code", json!("runtime_failed")),
("schema_version", json!(2)),
("event_time", json!("2026-09-21T00:00:00Z")),
("event_id", json!("not-a-uuid")),
("user_id", json!("")),
];
for (key, value) in mutate {
let mut raw = serde_json::to_value(&event).unwrap();
raw[key] = value;
let invalid: Event = serde_json::from_value(raw).unwrap();
assert!(invalid.validate().is_err(), "{key}");
}
for (key, value) in [
("duration_ms", json!(-1)),
("retry_index", json!(MAX_SAFE_INTEGER + 1)),
("duration_ms", json!(1.5)),
] {
let mut invalid = event.clone();
invalid.properties[key] = value;
assert!(invalid.validate().is_err());
}
}
#[test]
fn frozen_context_and_origin_do_not_inherit_new_account() {
let a = context();
let mut b = a.clone();
b.route = Route::from_identity(
Some("456".into()),
Some("https://other.example.com/api?token=private"),
);
let event = a
.capture(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("p".into()),
Source::Direct,
None,
)
.unwrap();
assert_eq!(event.user_id.as_deref(), Some("123"));
assert_eq!(
a.route.destination_origin.as_deref(),
Some("https://example.com")
);
assert_eq!(
b.route.destination_origin.as_deref(),
Some("https://other.example.com")
);
assert_eq!(
Route::from_identity(None, Some("file:///private")).destination_origin,
None
);
assert_eq!(
Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin,
None
);
assert!(
Route {
user_id: None,
destination_origin: Some("https://example.com/api".into())
}
.validate()
== false
);
}
@@ -1,55 +0,0 @@
//! 策划阶段推进按约定视为成果变化;不检查文档版本或记录工作流快照。
use super::contract::{ChangeKind, Context, EventData, RevisionCreated, RevisionSource, Source};
use super::store::AnalyticsWriter;
pub(crate) struct PhaseChange {
context: Context,
writer: AnalyticsWriter,
project_id: String,
revision_id: String,
}
impl PhaseChange {
pub(crate) fn new(
capture: Option<(Context, AnalyticsWriter)>,
project_id: &str,
session_id: &str,
previous_phase: &str,
phase: &str,
) -> Option<Self> {
if previous_phase == phase {
return None;
}
let (context, writer) = capture?;
Some(Self {
context,
writer,
project_id: project_id.to_string(),
revision_id: format!("design:{session_id}:{phase}"),
})
}
pub(crate) fn revision_id(&self) -> &str {
&self.revision_id
}
// 必须在阶段与审批命令成功持久化后调用,时间与事件 ID 在此刻生成。
pub(crate) fn record(self) {
let key = format!(
"{}:{}:project_revision_created",
self.project_id, self.revision_id
);
let data = EventData::ProjectRevisionCreated(RevisionCreated {
revision_id: self.revision_id,
revision_source: RevisionSource::Agent,
change_kind: ChangeKind::DesignDocument,
files_changed_count: None,
});
if let Ok(event) =
self.context
.capture(data, Some(self.project_id), Source::DesignAgent, None)
{
self.writer.try_record(self.context.route, event, key);
}
}
}
@@ -1,166 +0,0 @@
//! 新建项目的目标采集资格。只在后台持锁读改写,不等待业务线程。
use super::contract::{Context, EmptyProperties, Event, EventData, Route, Source};
use super::store::AnalyticsWriter;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MARKER: &str = ".agent/analytics-goal.json";
const MAX_MARKER_BYTES: usize = 4096;
#[derive(Serialize)]
pub(super) enum Request {
Created {
root: PathBuf,
project_id: String,
},
Accepted {
root: PathBuf,
route: Route,
event: Event,
},
}
impl Request {
pub(super) fn validate(&self) -> bool {
let (root, project_id) = match self {
Self::Created { root, project_id } => (root, Some(project_id.as_str())),
Self::Accepted { root, route, event } => {
if !route.validate()
|| route.user_id != event.user_id
|| event.event_name != "creative_task_submit"
|| event.validate().is_err()
|| !matches!(event.source, Source::Direct | Source::DesignAgent)
{
return false;
}
(root, event.project_id.as_deref())
}
};
root.is_absolute()
&& root.as_os_str().len() <= 32768
&& project_id.is_some_and(|id| {
!id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
})
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Marker {
schema_version: u32,
project_id: String,
submitted: bool,
}
pub(crate) fn created(writer: &AnalyticsWriter, root: &Path, project_id: &str) {
writer.try_goal(Request::Created {
root: root.into(),
project_id: project_id.into(),
});
}
pub(crate) fn accepted(
capture: Option<(Context, AnalyticsWriter)>,
root: &Path,
project_id: &str,
source: Source,
) {
if !matches!(source, Source::Direct | Source::DesignAgent) {
return;
}
let Some((context, writer)) = capture else {
return;
};
let Ok(event) = context.capture(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some(project_id.into()),
source,
None,
) else {
return;
};
writer.try_goal(Request::Accepted {
root: root.into(),
route: context.route,
event,
});
}
/// 成功消费资格才返回事件;消费后队列落盘失败允许漏记。
pub(super) fn process(request: Request) -> Result<Option<(Route, Event)>, String> {
if !request.validate() {
return Err("invalid analytics goal request".into());
}
let (root, project_id) = match &request {
Request::Created { root, project_id } => (root, project_id.as_str()),
Request::Accepted { root, event, .. } => (root, event.project_id.as_deref().unwrap()),
};
let Some(_lock) =
crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-goal")?
else {
return Ok(None);
};
let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?;
if crate::project::read_manifest(&manifest_path)?.project_id != project_id {
return Ok(None);
}
let path = crate::project::resolve_local_project_path(root, MARKER)?;
let primary = std::fs::symlink_metadata(&path);
match &request {
Request::Created { .. } => {
// 缺失主文件但存在恢复副本仍属未知,不能重新授予资格。
let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path);
if !matches!(primary, Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
|| !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
{
return Ok(None);
}
crate::agent::write_agent_runtime_json_sidecar_with_max_bytes(
root,
MARKER,
"埋点目标资格",
&Marker {
schema_version: 1,
project_id: project_id.into(),
submitted: false,
},
MAX_MARKER_BYTES,
)?;
Ok(None)
}
Request::Accepted { .. } => {
// 明确要求主文件存在,不启用 sidecar 的 previous 自动回退。
let Ok(metadata) = primary else {
return Ok(None);
};
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Ok(None);
}
let Some(mut marker): Option<Marker> =
crate::agent::read_agent_runtime_json_sidecar_with_max_bytes(
root,
MARKER,
"埋点目标资格",
MAX_MARKER_BYTES,
)?
else {
return Ok(None);
};
if marker.schema_version != 1 || marker.project_id != project_id || marker.submitted {
return Ok(None);
}
marker.submitted = true;
crate::agent::write_agent_runtime_json_sidecar_with_max_bytes(
root,
MARKER,
"埋点目标资格",
&marker,
MAX_MARKER_BYTES,
)?;
match request {
Request::Accepted { route, event, .. } => Ok(Some((route, event))),
_ => unreachable!(),
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,189 +0,0 @@
use super::*;
use std::fs;
use std::time::Duration;
fn state() -> (tempfile::TempDir, GuiState) {
let dir = tempfile::tempdir().unwrap();
let context = Context {
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
editor_session_id: Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
};
let writer = AnalyticsWriter::start(dir.path().into(), context.editor_session_id.clone());
(dir, GuiState::new(context, 1, writer))
}
fn read_events(dir: &std::path::Path, session: &str, expected: usize) -> Vec<Event> {
let until = Instant::now() + Duration::from_secs(5);
loop {
let mut events = Vec::new();
let batches = dir
.join("analytics/instances")
.join(session)
.join("batches");
if let Ok(entries) = fs::read_dir(batches) {
for entry in entries.flatten() {
if Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() {
continue;
}
if let Ok(text) = fs::read_to_string(entry.path().join("events.jsonl")) {
events.extend(
text.lines()
.map(|line| serde_json::from_str::<Event>(line).unwrap()),
);
}
}
}
if events.len() == expected {
return events;
}
assert!(
Instant::now() < until,
"expected {expected} events, found {}",
events.len()
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn window_union_and_repeated_notifications_form_one_interval() {
let (dir, mut state) = state();
state.start();
state.windows(Some("main".into()), false, true);
let interval = state.focus.as_ref().unwrap().id.clone();
state.windows(Some("launcher".into()), false, false);
state.windows(Some("launcher".into()), false, false);
assert_eq!(state.focus.as_ref().unwrap().id, interval);
state.windows(None, true, false);
state.windows(None, true, false);
state.exit();
state.exit();
let events = read_events(dir.path(), &state.context.editor_session_id, 4);
let end = events
.iter()
.find(|e| e.event_name == "editor_focus_end")
.unwrap();
assert_eq!(end.properties["focus_interval_id"], interval);
assert_eq!(end.properties["blur_reason"], "minimized");
assert_eq!(
events
.iter()
.filter(|e| e.event_name == "editor_session_end")
.count(),
1
);
}
#[test]
fn identity_change_splits_focus_but_refresh_and_stale_notice_do_not() {
let (dir, mut state) = state();
state.start();
state.windows(Some("main".into()), false, true);
let first = state.focus.as_ref().unwrap().id.clone();
open(
&mut state,
"project-a",
&Uuid::new_v4().to_string(),
"2000-01-01T00:00:00.000Z",
);
state.identity(state.context.route.clone(), 2);
assert_eq!(state.focus.as_ref().unwrap().id, first);
let route_b = Route::from_identity(Some("B".into()), Some("https://example.com"));
state.identity(route_b.clone(), 4);
state.identity(
Route::from_identity(Some("A".into()), Some("https://example.com")),
3,
);
assert_eq!(state.context.route, route_b);
assert!(state.projects.is_empty());
assert!(state.active_project.is_none());
assert_ne!(state.focus.as_ref().unwrap().id, first);
state.restart = true;
state.exit();
let events = read_events(dir.path(), &state.context.editor_session_id, 7);
let old_end = events
.iter()
.find(|e| e.event_name == "editor_focus_end" && e.user_id.as_deref() == Some("A"))
.unwrap();
assert_eq!(old_end.properties["blur_reason"], "account_change");
let new_start = events
.iter()
.find(|e| e.event_name == "editor_focus_start" && e.user_id.as_deref() == Some("B"))
.unwrap();
assert_eq!(new_start.properties["focus_reason"], "account_change");
assert!(new_start.project_id.is_none());
let end = events
.iter()
.find(|e| e.event_name == "editor_session_end")
.unwrap();
assert_eq!(end.properties["end_reason"], "app_restart");
}
fn open(state: &mut GuiState, project_id: &str, operation: &str, time: &str) {
state.reserve_open("main", operation, project_id, time);
state.project_opened(
"main".into(),
state.context.clone(),
OpenProject {
id: project_id.into(),
path: project_id.into(),
operation_id: operation.into(),
},
OpenSource::Recent,
time.into(),
);
}
#[test]
fn explicit_reopen_records_new_operation_and_duplicate_delivery_does_not() {
let (dir, mut state) = state();
let id = Uuid::new_v4().to_string();
open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z");
// 同一次已采纳操作的重复回执由幂等事实键抑制。
open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z");
let next = Uuid::new_v4().to_string();
assert_eq!(state.projects.get("main").unwrap().operation_id, id);
open(&mut state, "project", &next, "2026-09-21T12:00:00.002Z");
state.writer.flush();
let events = read_events(dir.path(), &state.context.editor_session_id, 2);
assert!(events.iter().any(|e| e.event_id == id));
assert!(events.iter().any(|e| e.event_id == next));
assert!(events[0].properties["is_first_open"].is_null());
assert_eq!(events[1].properties["is_first_open"], false);
}
#[test]
fn delayed_capture_or_manifest_read_does_not_restore_a_left_project() {
let (dir, mut state) = state();
state.windows(Some("main".into()), false, true);
let old = Uuid::new_v4().to_string();
state.reserve_open("main", &old, "old", "2026-09-21T12:00:00.001Z");
let new = Uuid::new_v4().to_string();
open(&mut state, "new", &new, "2026-09-21T12:00:00.002Z");
state.project_opened(
"main".into(),
state.context.clone(),
OpenProject {
id: "old".into(),
path: "old".into(),
operation_id: old,
},
OpenSource::Recent,
"2026-09-21T12:00:00.001Z".into(),
);
assert_eq!(state.active_project.as_deref(), Some("new"));
state.leave("main", Some("new"));
let late = Uuid::new_v4().to_string();
open(&mut state, "old", &late, "2000-01-01T00:00:00.000Z");
assert!(state.active_project.is_none());
state.writer.flush();
let events = read_events(dir.path(), &state.context.editor_session_id, 4);
assert_eq!(
events
.iter()
.filter(|e| e.event_name == "project_open")
.count(),
3
);
}
@@ -1,10 +0,0 @@
//! 客户端产品埋点:本地采集与持久化,不发起上传。
pub(crate) mod contract;
pub(crate) mod design;
pub(crate) mod goal;
pub(crate) mod gui;
pub(crate) mod preview;
pub(crate) mod project;
pub(crate) mod run;
pub(crate) mod session;
pub(crate) mod store;
@@ -1,146 +0,0 @@
//! 正式 Web 预览的短时可访问性观察;不影响预览业务生命周期。
use super::{
contract::{Context, EventData, PreviewReady, PreviewSource, Source},
store::AnalyticsWriter,
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Weak},
time::{Duration, Instant},
};
pub(crate) struct Lease {
_alive: Arc<()>,
}
pub(crate) struct Observation {
alive: Weak<()>,
instance_id: String,
root: PathBuf,
entry: PathBuf,
project_id: String,
revision: u64,
context: Context,
writer: AnalyticsWriter,
source: Source,
preview_source: PreviewSource,
started: Instant,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
}
fn nonempty_entry(entry: &Path) -> bool {
std::fs::metadata(entry).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
pub(crate) fn prepare(
root: &Path,
capture: Option<(Context, AnalyticsWriter)>,
source: Source,
preview_source: PreviewSource,
) -> Option<(Lease, Observation)> {
let (context, writer) = capture?;
let entry = crate::project_game_root(root).join("index.html");
if !nonempty_entry(&entry) {
return None;
}
let project_id = crate::read_existing_manifest_for_project(root)
.ok()?
.project_id;
let revision = crate::read_game_creator_agent_runtime_project_revision(root)
.ok()?
.revision;
let alive = Arc::new(());
let observation = Observation {
alive: Arc::downgrade(&alive),
instance_id: uuid::Uuid::new_v4().to_string(),
root: root.into(),
entry,
project_id,
revision,
context,
writer,
source,
preview_source,
started: Instant::now(),
cancellation: None,
};
Some((Lease { _alive: alive }, observation))
}
impl Observation {
pub(crate) fn with_cancellation(
mut self,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Self {
self.cancellation = cancellation;
self
}
fn current(&self) -> bool {
!self
.cancellation
.as_ref()
.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire))
&& self.alive.strong_count() > 0
&& self.entry == crate::project_game_root(&self.root).join("index.html")
&& nonempty_entry(&self.entry)
&& crate::read_game_creator_agent_runtime_project_revision(&self.root)
.is_ok_and(|revision| revision.revision == self.revision)
&& crate::read_existing_manifest_for_project(&self.root)
.is_ok_and(|manifest| manifest.project_id == self.project_id)
}
pub(crate) fn schedule(self, port: u16) {
tauri::async_runtime::spawn(self.observe(port));
}
pub(crate) async fn observe(self, port: u16) {
if !self.current() {
return;
}
let reachable = tokio::time::timeout(Duration::from_secs(2), async {
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.ok()?;
let mut response = client
.get(format!("http://127.0.0.1:{port}/"))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
while let Some(chunk) = response.chunk().await.ok()? {
if !chunk.is_empty() {
return Some(());
}
}
None
})
.await;
if !matches!(reachable, Ok(Some(()))) || !self.current() {
return;
}
let event = self.context.capture(
EventData::PreviewReady(PreviewReady {
preview_source: self.preview_source,
preview_version: self.revision.to_string(),
ready_duration_ms: u64::try_from(self.started.elapsed().as_millis()).ok(),
}),
Some(self.project_id.clone()),
self.source,
None,
);
if let Ok(event) = event {
if self.current() {
let key = format!(
"{}:{}:{}:preview_ready",
self.project_id, self.instance_id, self.revision
);
self.writer.try_record(self.context.route, event, key);
}
}
}
}

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