Compare commits

..

60 Commits

Author SHA1 Message Date
k88936 11d4d3d930 适配 UI 分离图片编辑 multipart 接口
Project CI / Frontend tests (pull_request) Successful in 4m57s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Failing after 5m40s
将标记图 data URL 解码为 PNG 文件部件。

按 Raw GPT Image 2 新合同发送 prompt、尺寸和输出参数。

保留响应 data[].b64_json 解析与 separation 流程不变。
2026-09-09 13:17:36 +08:00
k88936 418d5732de 调整UI识别指令,细化粒度要求并优化组件返回规则 2026-09-09 13:12:51 +08:00
k88936 592b6b4ee7 简化识别树验证测试中的断言格式 2026-09-09 13:12:51 +08:00
k88936 99c19e3ed9 补充分离流程排障日志
记录 separation sidecar 状态读写与恢复阶段

记录树构造、批次选择、视觉绑定、裁切和 patch 结果

避免输出 base64、完整提示词和敏感凭据
2026-09-09 13:12:51 +08:00
k88936 15e30acb0f 调整UI识别指令细节,明确文字组件识别规则与粒度标准 2026-09-09 13:12:51 +08:00
k88936 56fff8325b 将自动分离图像处理移出异步执行器
在 spawn_blocking 中构建标记图并编码写入处理图

在 spawn_blocking 中完成处理图裁切与文件输出

保留 image-edit 和视觉绑定网络请求的 async 调度边界
2026-09-09 13:12:51 +08:00
k88936 884c5ec5ee 补充自动分离 sidecar 并发边界约束
记录前端单次运行与 runWithStateLocked 的并发前提

明确 sidecar 不参与正式资产写入和项目 revision

补充未来多窗口多进程场景的进程级锁 TODO

记录图像本地处理使用 spawn_blocking 的执行边界
2026-09-09 13:12:51 +08:00
k88936 207b0820dd 统一分离树文件格式
按仓库 rustfmt 规范整理 synthetic root 实现
2026-09-09 13:12:51 +08:00
k88936 19f3d62d78 隔离非法分离区域导致的批次失败
将裁切错误记录为 problematic 并继续流程
避免单个模型区域终止整页分离
2026-09-09 13:12:51 +08:00
k88936 3c1e7bd599 保留分离问题节点的真实重做次数
避免基础设施失败伪造达到上限
保持 problematic 节点诊断信息准确
2026-09-09 13:12:51 +08:00
k88936 dda6b3946a 阻止识别结果引用未授权字体素材
补齐识别阶段字体引用校验
增加伪造字体绑定回归测试
2026-09-09 13:12:51 +08:00
k88936 aff40f794d 修复分离树合成根节点 ID 冲突
避免单图根节点重复进入重做计数
补充根图片构造回归测试
2026-09-09 13:12:50 +08:00
k88936 187faa607d 保留原始分离提示并拆分模块
按模型、树、持久化、提示词和工作流拆分 separation

提取支持 more_turn 的反馈重试 harness

删除旧 separation.rs 文件
2026-09-09 13:12:50 +08:00
k88936 109cc81740 拆分UI自动分离命令模块
保留原始图片分离与绑定提示词

提取模型、树、持久化和工作流子模块

新增可配置 more_turn 反馈重试 harness
2026-09-09 13:12:50 +08:00
k88936 f796aef7e2 完善UI自动分离批次处理
复用资源ID安全路径并修正像素坐标

加入标记图、Raw编辑、视觉绑定修复与问题节点继续处理
2026-09-09 13:12:50 +08:00
k88936 4cc7f4dd64 实现UI自动分离命令
新增分离树、批次处理与sidecar状态

接入Raw GPT Image 2与视觉绑定重试

注册Tauri命令并补充识别组件草稿
2026-09-09 13:12:50 +08:00
k88936 b2d4690f94 补充UI编辑器自动分离工作流合同
新增自动分离、临时 sidecar 与视觉绑定设计合同

明确前端资产登记职责与恢复 TODO
2026-09-09 13:12:50 +08:00
k88936 0400103a19 将 Raw 图片编辑入站改为 multipart
移除 JSON/base64 入站兼容,仅接受 multipart 图片与参数字段

复用扣费前 PNG、尺寸、mask 和 prompt 校验并补充 multipart 测试

优化上游图片字节转发,避免再次克隆 image bytes

同步 Raw 图片编辑技术方案与 Axum multipart 依赖
2026-09-09 13:06:22 +08:00
k88936 c049d68b8e 收紧 Raw 成功响应字段类型
要求 data 与 b64_json 必填并移除无用 provider id

prompt 上限调整为 4 KiB 原始字节并同步文档
2026-09-09 12:03:03 +08:00
k88936 acfcf0c158 同步 platform-image 的 serde 锁定依赖
为 Raw typed 响应记录 serde workspace 依赖
2026-09-09 11:51:25 +08:00
k88936 5bbedd9a5c 限制 Raw prompt 原始字节大小
将 prompt UTF-8 原始字节限制为 16 KiB

补充超限回归测试并同步请求合同
2026-09-09 11:50:30 +08:00
k88936 40316d82c4 在扣费前校验 Raw mask 尺寸
解码时保留 image 与 mask 宽高并拒绝不一致输入

同步 Raw 图片编辑请求合同与回归测试
2026-09-09 11:45:44 +08:00
k88936 6c77de4a36 为 Raw 成功响应收紧类型透传
使用 typed JSON DTO 提取并原样转发 b64_json

不解码图片内容且忽略 output_format 回显
2026-09-09 11:44:31 +08:00
k88936 b3693765ef 透传 Raw 图片结果避免解码回显字段
仅转发上游 data.b64_json 并忽略 output_format

同步 Raw 技术方案并补充不解码回归测试
2026-09-09 11:34:41 +08:00
k88936 f7edc9d0ee 区分 Raw 图片 PNG 资源限制错误
Project CI / Frontend tests (pull_request) Successful in 4m43s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Native shell tests (pull_request) Failing after 5m14s
将有效但超出解码限制的 PNG 映射为明确的 400 提示

补充超大合法 PNG 的回归测试
2026-09-09 11:04:44 +08:00
k88936 f60771dd00 提前检查 Raw 图片请求预算
在构造 multipart 表单前校验请求 deadline

避免预算耗尽时复制输入图片并缩短耗时统计范围
2026-09-09 10:58:48 +08:00
k88936 f7cccd8592 补充 Raw 图片客户端错误上下文
为 HTTP 客户端构造失败统一保留操作上下文

补充配置错误文案回归测试
2026-09-09 10:57:04 +08:00
k88936 5ee2d940c1 修正 Raw 图片尺寸校验错误语义
尺寸校验失败改用请求参数无效语义

补充错误文案回归测试
2026-09-09 10:55:36 +08:00
k88936 c75a7725ce Merge remote-tracking branch 'origin/master' into feat/expose-image2edit
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Failing after 4m9s
Project CI / Backend tests (pull_request) Failing after 4m58s
Project CI / Frontend tests (pull_request) Successful in 3m46s
2026-09-09 10:42:22 +08:00
k88936 3a95715990 同步 Raw 图片编辑技术方案
Project CI / Native shell tests (pull_request) Failing after 6m36s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 4m22s
记录解码资源上限和阻塞线程策略
补充输出格式回退与成功追踪约定
2026-09-08 23:41:27 +08:00
k88936 ed86963de2 修正 Raw 图片追踪结果类型
明确输出数据集合类型
保持 API 编译检查通过
2026-09-08 23:38:25 +08:00
k88936 65e73eee02 补充 Raw 图片生成成功追踪
记录成功任务与输出数量
完善上游响应和失败阶段日志
2026-09-08 23:25:47 +08:00
k88936 6068eeda83 补充 Raw 图片编辑请求日志
记录上游响应状态与耗时
记录传输失败阶段和结构化上下文
2026-09-08 23:22:56 +08:00
k88936 0e244c64ea 统一 GPT Image 2 尺寸常量
集中维护像素边界和对齐规则
供生成与编辑路径共同引用
2026-09-08 22:11:35 +08:00
suzmii a0e6a83691 修复 AGC 模型选择入口、交互与默认模型回退 (#299)
Project CI / Backend tests (push) Failing after 7m10s
Project CI / Repository checks (push) Successful in 2m19s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Native shell tests (push) Failing after 19m48s
### 背景

AGC 后台已支持维护 LLM 模型目录(`agc_model_catalog`),客户端此前只在项目右侧对话浮出模型选择,且存在几个体验问题:

1. 首页聊天框架缺少模型选择入口;
2. 首次进入项目时,右侧模型选择器「点不动」(被 disabled);
3. 模型选择下拉失去焦点不自动收起;
4. 默认模型缺少可视标识;
5. 已保存的选择被后台停用/移除后,客户端显示「请选择模型」,未自动回退默认模型;首页因懒加载在打开前也一直显示「选择模型」。

### 改动内容(完整变更集)

- **首页聊天框架新增模型选择入口**,复用项目右侧对话的 `ConversationModelSelect`;上传按钮在左,模型选择器与发送按钮在右(同一组,模型选择器紧挨发送按钮)。
- **修复首次进入项目时右侧模型选择器点不动**:选择器不再因目录加载(`busy`)或对话进行中(`controlBusy`)被禁用;切换模型只写回客户端配置并作用于后续轮次,发送仍由 `controlBusy` / `modelReady` 把关。
- **模型下拉支持点击外部 / Esc 自动收起**:给组件容器挂 ref,监听 `document` 的 `mousedown` 与 `keydown`,点击容器外或按 Esc 即收起(仅在展开时挂监听)。
- **默认模型增加「默认」标识**:组件记录 `defaultModelId`,在下拉选项中给默认模型显示小标签;亮暗主题自适应。
- **模型选择始终回退默认模型**:已保存选择仍可用时沿用;被停用/移除或从未选择时回退到后台默认模型并写回配置;只有默认模型本身不可用才走错误分流。首页由「懒加载」改为「挂载即加载」,触发按钮直接落在默认模型上,不再出现「选择模型」空态。
- **清理**:移除组件中已无调用方的 `lazy` 加载路径。
- **文档**:同步更新 `【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`(首页入口、对话进行中可切换模型、失焦收起等行为)。

### 关键交互/行为说明

- 触发按钮永远显示模型名(默认或当前选择),不再出现「选择模型」占位,除非默认模型不可用。
- 模型下拉的加载中、刷新、选中态沿用原有交互;默认模型有「默认」标签,便于识别。
- 首页模型选择器不改动「开启创作」门控:目录不可用时仍可创建项目并走后台默认项。

### 验证

- `npm run test -- apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx` -> 7/7 通过
- `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts` -> 386/386 通过
- `npm run ai-game-creator-shell:typecheck` 通过
- `npm run check:encoding`(4309 文件)通过
- `git diff --check` 通过

### 影响范围与注意事项

- 变更集中在 AGC 客户端(`apps/ai-game-creator-shell`),不涉及 SpacetimeDB schema / 后端接口 / 契约。
- `GET /api/llm/models` 语义不变(仍只返回启用项 + 默认项),本次不改后端。
- 模型下拉在窄屏的换行/遮挡,以及「默认」标签对比度,建议在真机确认一下观感(单测只覆盖逻辑)。

### 关联

- 无关联 Issue;本 PR 独立提交。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: #299
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-09-08 22:05:15 +08:00
k88936 d3f5d0a355 feat/AGC codex的工具调用 持久化处理 (#282)
Project CI / Repository checks (push) Successful in 2m7s
Project CI / Frontend tests (push) Successful in 2m46s
Project CI / Backend tests (push) Failing after 3m53s
Project CI / Native shell tests (push) Failing after 3m50s
重构directproject的thread持久化,  保存response items用来恢复thread

修改了DirectProject的对话jsonl格式,  不兼容不迁移. (测试需要手动清理旧对话jsonl文件)

AGC显式写入user msg, 对codex 返回的user msg忽略
清理原来的上下文滑动窗口, 因为codex内部会自动compact

工具调用后重启,继续对话:
![shotmd-1788579616.jpg](/attachments/8350c924-9498-47cd-b3da-8be6b79696f9)

close #249
close #277

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: #282
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 22:04:37 +08:00
k88936 01e057d047 Feat/AGC对话渲染markdown (#281)
Project CI / Frontend tests (push) Successful in 3m37s
Project CI / Backend tests (push) Failing after 4m13s
Project CI / Native shell tests (push) Failing after 4m11s
Project CI / Repository checks (push) Successful in 3m24s
before:
 ![image.png](/attachments/ca6f46f7-dcbd-4132-bad2-cf5c83c49096)

after:
 ![shotmd-1788500615.jpg](/attachments/719539a1-9dfb-412a-a9b9-298547b1791c)
![shotmd-1788500598.jpg](/attachments/4656815f-caca-4325-bca3-b29fe5742b6e)

考虑到在AGC场景下不是很有理由, 暂不实现链接, 图片, 内嵌html

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: #281
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 22:03:37 +08:00
kdletters 1f904d28e9 AGC 客户端 MCP 能力暴露 (#274)
Project CI / Frontend tests (push) Successful in 4m2s
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Backend tests (push) Successful in 10m14s
Project CI / Native shell tests (push) Failing after 19m54s
## 目标
保留现有客户端对话与 Codex app-server 链路,把客户端自身受控业务能力通过 MCP 暴露给 Codex。

## 范围
- 客户端会话、项目文件、资源、画布、生成、预览等稳定能力
- 审核 Skill 的索引与按需指导资源
- 复用现有账号、项目路径、权限、计费、幂等、锁和恢复边界

## 明确不做
- 不替换客户端对话入口或 Codex app-server
- 不让客户端替 Codex 判断高层意图、完成状态或规划
- 不暴露任意 Tauri command、shell、凭据、内部 URL、数据库和管理能力

当前 PR 先建立独立分支与审查边界,后续提交实现与定向验证。

Reviewed-on: #274
2026-09-08 22:01:29 +08:00
k88936 ad7c059603 遵守 Raw 图片请求截止时间
发送前裁剪有效超时预算
预算耗尽时提前返回结构化错误
2026-09-08 21:54:22 +08:00
k88936 55d2a393d1 完善 Raw 图片尺寸错误类型
为公开错误实现 Error trait
支持通用错误链处理
2026-09-08 21:52:15 +08:00
k88936 e7f66ce950 修正 Raw 图片传输失败阶段标记
区分请求发送和响应读取失败
让错误详情准确反映故障阶段
2026-09-08 20:03:13 +08:00
k88936 35dcfea3ef 修正 Raw 图片输出格式回退
忽略空值后再使用载荷级格式
补充空字符串和 null 回归测试
2026-09-08 19:56:41 +08:00
k88936 cea8bf77fa 减少 Raw 图片编辑请求拷贝
移动 multipart 选项字段而非克隆
降低掩码和字符串的峰值内存
2026-09-08 19:52:13 +08:00
k88936 0136bfe564 移出 Raw 图片同步解码
将请求预校验放入阻塞线程
避免占用 Tokio 异步工作线程
2026-09-08 19:48:58 +08:00
k88936 22785b4d7b 限制 Raw 图片解码资源
为 PNG 解码设置边长和分配上限
避免压缩图片膨胀导致内存耗尽
2026-09-08 19:45:02 +08:00
k88936 a86a2ee4d2 简化 Raw 图片编辑响应类型
移除无必要的 JSON Value 转换
保持响应结构由类型系统校验
2026-09-08 19:32:55 +08:00
k88936 b22eae2726 收紧 Raw 图片编辑输入契约
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / Native shell tests (pull_request) Successful in 20m20s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
新增 3840 边长、16 像素对齐、3:1 比例和总像素范围校验

要求 image 与 mask 为可完整解码的 PNG 并在扣费前拒绝非法输入

将 Raw 路由 JSON body limit 提升至 64 MiB

同步 Raw 图片编辑技术方案和边界测试
2026-09-08 18:18:58 +08:00
k88936 35d0db6377 修复 Raw 图片失败审计与响应格式
Project CI / Frontend tests (pull_request) Successful in 6m7s
Project CI / Native shell tests (pull_request) Successful in 19m41s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 9s
为 Raw provider 失败分支补齐结构化 failure audit 并接入 api-server 记录链

使用 VectorEngine 响应 output_format 生成返回图片 MIME 与扩展名

补充响应格式和审计字段定向测试并同步技术方案
2026-09-08 16:19:29 +08:00
kdletters 1498247e9f AGC产物迁移npm与Phaser4
Project CI / Repository checks (push) Successful in 2m54s
Project CI / Native shell tests (push) Failing after 16m56s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 8m10s
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架

允许 DirectProject 在项目边界内安装依赖并执行构建

让预览、静态检查、投影和试玩导出统一使用 dist 产物

补充 Phaser Skill、技术方案、项目记忆和定向测试
2026-09-08 15:11:36 +08:00
suzmii 456cc42dd3 优化泥点流水的素材消耗原因展示 (#302)
Project CI / Repository checks (push) Successful in 1m59s
Project CI / Frontend tests (push) Successful in 3m7s
Project CI / Backend tests (push) Successful in 6m13s
Project CI / Native shell tests (push) Successful in 19m24s
Close #296

## 问题

钱包流水目前只把素材类扣费暴露为 `asset_operation_consume`,共享账单组件统一显示“资产操作消耗”,无法说明本次泥点实际用于哪类素材生成。

## 落地方案

- 在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,不修改 SpacetimeDB schema。
- `api-server` 只将内部素材类型白名单映射为用户可见 `reason`,不暴露原始 metadata、资源 ID 或任务 ID。
- 共享钱包流水契约与组件优先展示具体原因;历史、未知或空 metadata 继续回退为“资产操作消耗”。
- 主站、图片画布与 AGC 继续复用同一共享账单组件。

## 当前进度

- [x] 补充权威后端数据契约
- [x] 写入素材操作类型 metadata
- [x] 扩展钱包流水公开 DTO 与安全映射
- [x] 更新共享账单展示与回归测试
- [x] 完成定向验证与边界检查

## 用户可见映射

- 图片、图标图集、美术规范、UI 设计、发布素材:生成美术素材
- 图片修改:编辑美术素材
- UI 素材提取:提取美术素材
- 角色动画:生成角色动画
- 视频:生成视频素材
- 音效:生成音效素材
- 背景音乐:生成背景音乐
- 历史、未知、空 metadata:资产操作消耗

## 验证

- `cargo fmt --all -- --check`
- `cargo test -p api-server profile_wallet_ledger_reason_only_exposes_known_asset_operations`
- `cargo test -p api-server worker_billing_context_freezes_charge_and_preserves_job_metadata`
- `cargo test -p shared-contracts profile_wallet_ledger_response_uses_camel_case_fields`
- `npx vitest run packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx`
- `npm run typecheck`
- `npx eslint packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx packages/shared/src/contracts/runtime.ts`
- `npm run check:encoding`
- `npm run check:spacetime-schema`
- `git diff --check`

以上均通过;Rust 仅输出仓库已有 dead-code warnings。

## 未验证边界

- 未执行真实登录、生成并读取新钱包流水的端到端联调。本机 `8082/3101` 已由另一套本地栈占用,为避免当前分支挂接同一数据库并带起 worker 处理现有队列,本轮未启动 `npm run dev:api-server`。
- 四项远端 CI 已全部通过,PR 已转为 Ready for review。

## 不变项

- 不修改历史流水数据。
- 不调整泥点价格、扣费顺序、幂等 ledger、失败退款或余额结算。
- 不修改 SpacetimeDB schema。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/302
Co-authored-by: Suzumiya <suzmii@qq.com>
Co-committed-by: Suzumiya <suzmii@qq.com>
2026-09-08 14:56:16 +08:00
k88936 f087ba3e2b 隔离 Raw 图片代理实现
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 36m40s
Project CI / Native shell tests (pull_request) Successful in 54m1s
移除对现有图片编辑 client 与 curl transport 的复用

将 Raw GPT Image 2 请求、multipart 构造和响应解析收敛到独立文件

保持现有 editor 图片链路代码不变
2026-09-08 13:54:31 +08:00
k88936 7fc1b841be 收口 Raw 图片 base64 校验错误
客户端仅返回稳定的 base64 格式错误提示

补充测试避免泄露解码器内部诊断细节
2026-09-08 12:30:55 +08:00
k88936 a576fdcc37 移除未使用的 multipart 请求包装函数
无选项测试直接调用统一传输入口

避免生产构建保留未使用函数告警
2026-09-08 12:29:59 +08:00
k88936 4dcf38715b 补充图片编辑选项 multipart 测试
覆盖质量、背景、输出格式与 mask 文件字段

断言文件名、MIME 类型和字节内容均正确发送
2026-09-08 12:21:11 +08:00
k88936 d1f9f4e1ef 减少图片编辑 multipart 字节复制
将已克隆的选项按值传入阻塞传输函数

构造 mask 与参考图表单时直接移动字节缓冲
2026-09-08 12:16:00 +08:00
k88936 5836aaec26 简化图片编辑 multipart 请求分支
统一通过带选项的 multipart 传输函数发送请求

移除冗余包装函数调用与未使用导入
2026-09-08 12:10:56 +08:00
k88936 4db6d28603 UI编辑器一批细节优化 (#278)
Project CI / Repository checks (push) Successful in 3m6s
Project CI / Frontend tests (push) Successful in 3m44s
Project CI / Backend tests (push) Successful in 8m16s
Project CI / Native shell tests (push) Successful in 20m48s
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现
* 支持Delete删除节点 ctrl + -缩放
* 步骤结束弹窗提醒
![shotmd-1788599068.jpg](/attachments/99a1e4aa-d778-41bf-9dda-0df703723407)
![shotmd-1788605840.jpg](/attachments/31ab883d-32bc-467b-9308-616b83387a79)

* UI 树快捷删除
![shotmd-1788605655.jpg](/attachments/3d451201-3f68-4eb6-b558-864ecf0cb5a3)

* zoom滑动条样式

before:
![image.png](/attachments/e4a04304-e6eb-4da6-9352-99e093fee42e)
after:
![shotmd-1788606577.jpg](/attachments/6cdc377c-f2a3-4a7e-b78e-772a3fdb13b7)

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-08 11:56:36 +08:00
k88936 18d60feb04 补充 Raw 图片参数预检查
在计费前校验质量、背景和输出格式

规范化可选参数并保持单图请求契约
2026-09-08 10:40:09 +08:00
k88936 4e1316e272 实现 Raw GPT Image 2 图片编辑代理
新增受 Bearer 保护的 /api/raw/v1/images/edit JSON 路由

按单图 image 与可选 mask 转发 GPT Image 2 参数

固定 model 与 n 并返回仅含 data[].b64_json 的响应

复用钱包计费与退款边界并补充单元测试
2026-09-08 10:36:48 +08:00
k88936 302b4addae 补充 Raw GPT Image 2 代理方案
Project CI / Native shell tests (pull_request) Successful in 55m40s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 23m43s
明确 JSON 请求与 data 数组响应

记录预检查和钱包事务边界

拆分 api-server 与 platform-image 文件职责
2026-09-07 16:33:34 +08:00
136 changed files with 9431 additions and 1601 deletions
+1
View File
@@ -53,6 +53,7 @@
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"phaser": "^4.2.1",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
@@ -107,12 +107,16 @@ const rustSharedContractSource = fs.readFileSync(
'utf8',
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
];
const sourceExtensions = new Set([
'.json',
@@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
});
}
function readBackendServiceFailure(
state,
{
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
} = {},
) {
const targets = resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
expectedDatabase,
expectedSpacetimeDataDir,
});
if (!targets.hasMatchingBackend) {
return null;
}
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
const service = state?.services?.[serviceName];
if (service?.status !== 'failed') {
continue;
}
return {
serviceName,
failure: service.signal
? `signal=${service.signal}`
: `code=${service.exitCode ?? 1}`,
};
}
return null;
}
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
@@ -505,11 +538,29 @@ async function terminateChildTree(
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
async function waitForBackendReady(
backendChild,
timeoutMs = 600_000,
{
checkBackendReady = isBackendReady,
readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets,
} = {},
) {
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
if (await checkBackendReady()) {
return resolveTargets();
}
const state = readState();
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
const serviceFailure = readBackendServiceFailure(state);
if (serviceFailure) {
throw new Error(
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
);
}
}
const failure = readChildFailure(backendChild);
if (failure) {
@@ -686,6 +737,7 @@ export {
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
File diff suppressed because it is too large Load Diff
@@ -1,21 +1,22 @@
---
name: agc-web-game-development
description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
description: Build or modify a playable npm-managed Phaser 4 web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work.
---
# AGC Web Game Development
Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template.
Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI.
## Workflow
1. Read the existing `index.html`, `style.css`, and `game.js` before modifying an existing game.
2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets.
3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
5. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally.
7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`).
2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them.
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`.
@@ -1,4 +1,4 @@
interface:
display_name: "Web 游戏实现"
short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏"
short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏"
default_prompt: "Use $agc-web-game-development to build or modify the current playable web game."
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.10",
"version": "2026-08-26.12",
"skills": [
{
"name": "agc-project-structure",
@@ -57,7 +57,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff"
"sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a"
},
{
"name": "agc-browser-playtest",
@@ -14,6 +14,8 @@ mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -38,6 +40,8 @@ pub(crate) use codex_cli::{
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,467 @@
use crate::config::prepare_game_creator_private_path_for_read;
use crate::project::{
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
};
use crate::{LocalConversationMessageRecord, LocalConversationResult};
use serde_json::Value;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
"host_skills.instructions",
"permissions.instructions",
"environments.environment_context",
];
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
("<environment_context>", "</environment_context>"),
("<skill>", "</skill>"),
("<user_shell_command>", "</user_shell_command>"),
("<turn_aborted>", "</turn_aborted>"),
("<subagent_notification>", "</subagent_notification>"),
("<recommended_plugins>", "</recommended_plugins>"),
("<goal_context>", "</goal_context>"),
];
fn is_known_contextual_user_text(text: &str) -> bool {
let text = text.trim();
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
.iter()
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
{
return true;
}
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
return true;
}
text.starts_with("<external_")
&& text
.split_once('>')
.and_then(|(start, _)| start.strip_prefix("<external_"))
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
}
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
if matches!(
item.get("role").and_then(Value::as_str),
Some("developer" | "system")
) {
return true;
}
if item
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
.and_then(Value::as_array)
.is_some_and(|kinds| {
kinds
.iter()
.filter_map(Value::as_str)
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
})
{
return true;
}
item.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
let Some(text) = part.get("text").and_then(Value::as_str) else {
return false;
};
is_known_contextual_user_text(text)
})
})
}
fn history_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/project.jsonl")
}
fn record(item: &Value) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
"payload": item,
}))
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
}
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
fn find_direct_project_history_item_by_id_at(
path: &Path,
item_id: &str,
) -> Result<Option<Value>, String> {
let mut file = File::open(path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut position = file
.metadata()
.map_err(|error| {
format!(
"读取 DirectProject 历史元数据失败:{}: {error}",
path.display()
)
})?
.len();
let mut pending = Vec::new();
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
loop {
if position == 0 {
break;
}
let read_len = usize::try_from(position)
.unwrap_or(usize::MAX)
.min(chunk.len());
position -= read_len as u64;
file.seek(SeekFrom::Start(position))
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
file.read_exact(&mut chunk[..read_len])
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut combined = Vec::with_capacity(read_len + pending.len());
combined.extend_from_slice(&chunk[..read_len]);
combined.extend_from_slice(&pending);
let mut line_end = combined.len();
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
let line = &combined[newline + 1..line_end];
if !line.is_empty() {
if let Some(item) = direct_project_history_item_from_line(path, line)? {
if item.get("id").and_then(Value::as_str) == Some(item_id) {
return Ok(Some(item));
}
}
}
line_end = newline;
}
pending = combined[..line_end].to_vec();
}
if !pending.is_empty() {
// The append path repairs an unterminated final JSONL record before
// writing. A duplicate scan must not reject that repairable tail.
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
if item.get("id").and_then(Value::as_str) == Some(item_id) {
return Ok(Some(item));
}
}
}
Ok(None)
}
fn direct_project_history_item_from_line(
path: &Path,
line: &[u8],
) -> Result<Option<Value>, String> {
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
return Ok(None);
}
let parsed: Value = serde_json::from_slice(line)
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return Err(format!(
"DirectProject 历史记录类型无效:{}",
path.display()
));
}
let item = parsed
.get("payload")
.cloned()
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()))?;
if is_direct_project_internal_context_item(&item) {
return Ok(None);
}
Ok(Some(item))
}
pub(crate) fn append_direct_project_history_item_at(
root: &Path,
item: &Value,
) -> Result<(), String> {
append_direct_project_history_item_at_with_user_policy(root, item, false)
}
/// Appends a user message authored by AGC itself. Codex response items use
/// the default path above, which deliberately ignores echoed user messages;
/// this explicit entry point keeps the two sources distinct.
pub(crate) fn append_direct_project_user_message_at(
root: &Path,
item: &Value,
) -> Result<(), String> {
append_direct_project_history_item_at_with_user_policy(root, item, true)
}
fn append_direct_project_history_item_at_with_user_policy(
root: &Path,
item: &Value,
allow_user_item: bool,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
if is_direct_project_internal_context_item(item) {
return Ok(());
}
if !allow_user_item && is_direct_project_codex_user_item(item) {
return Ok(());
}
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
let path = history_path(root);
let history_exists =
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
let lock = project_append_lock_for(&path)?;
let _append_guard = lock.lock("DirectProject 历史追加写")?;
if history_exists {
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
if &existing == item {
return Ok(());
}
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
}
}
}
let line = record(item)?;
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
}
fn is_direct_project_codex_user_item(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
return true;
}
if item.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
!item
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
}
pub(crate) fn direct_project_local_message_item(
role: &str,
content: &str,
message_id: Option<&str>,
) -> Result<Value, String> {
let role = role.trim();
let content = content.trim();
if content.is_empty() || !matches!(role, "user" | "assistant") {
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
}
let mut item = serde_json::json!({
"type": "message",
"role": role,
"content": [{
"type": if role == "user" { "input_text" } else { "output_text" },
"text": content,
}],
});
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
item["id"] = Value::String(message_id.to_string());
}
Ok(item)
}
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
let mut items = Vec::new();
let mut reader = BufReader::new(file);
loop {
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
if bytes == 0 {
break;
}
let had_newline = line.ends_with('\n');
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(_error) if !had_newline => break,
Err(error) => {
return Err(format!(
"解析 DirectProject 历史失败:{}: {error}",
path.display()
));
}
};
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
return Err(format!(
"DirectProject 历史记录类型无效:{}",
path.display()
));
}
let item = parsed
.get("payload")
.cloned()
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload{}", path.display()))?;
if is_direct_project_internal_context_item(&item) {
continue;
}
items.push(item);
}
Ok(items)
}
pub(crate) fn read_direct_project_chat_history_at(
root: &Path,
) -> Result<LocalConversationResult, String> {
let path = history_path(root);
let items = read_direct_project_history_items_at(root)?;
let messages = items
.into_iter()
.filter_map(|item| {
let role = item.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant") {
return None;
}
let content = item
.get("content")
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
(!content.is_empty()).then(|| LocalConversationMessageRecord {
schema_version: "agc-direct-project-context.v1".to_string(),
role: role.to_string(),
content,
agent_id: None,
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
updated_at: 0,
})
})
.collect();
Ok(LocalConversationResult {
path: path.to_string_lossy().into_owned(),
agent_id: None,
session_id: None,
messages,
})
}
#[cfg(test)]
mod tests {
use super::{
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
is_direct_project_internal_context_item, read_direct_project_history_items_at,
};
use serde_json::json;
#[test]
fn filters_host_context_but_keeps_real_user_items() {
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
})));
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "<environment_context>"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["environments.environment_context"]
}
})));
assert!(!is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ls ui"}],
"internal_chat_message_metadata_passthrough": {
"content_item_kinds": ["user.text"]
}
})));
}
#[test]
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
assert!(is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
}]
})));
assert!(!is_direct_project_internal_context_item(&json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "请读取 <environment_context> 中的说明"
}]
})));
}
#[test]
fn append_repairs_truncated_tail_before_idempotency_scan() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
.expect("init project");
let path = history_path(root.path());
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
std::fs::write(
&path,
br#"{"type":"response_item","payload":{"type":"message"}"#,
)
.expect("write truncated history tail");
let item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": "tail-repair-item",
"content": [{"type": "output_text", "text": "已修复"}]
});
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
let items =
read_direct_project_history_items_at(root.path()).expect("read repaired history");
assert_eq!(items, vec![item]);
}
#[test]
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
.expect("init project");
let user = serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-0001:user",
"content": [{"type": "input_text", "text": "请创建菜单"}]
});
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
append_direct_project_history_item_at(
root.path(),
&serde_json::json!({
"type": "userMessage",
"id": "codex-user-item-1",
"clientId": "turn-0001",
"content": [{"type": "text", "text": "请创建菜单"}]
}),
)
.expect("ignore Codex echo");
append_direct_project_history_item_at(
root.path(),
&serde_json::json!({
"type": "message",
"role": "user",
"id": "codex-raw-user-item-1",
"content": [{"type": "input_text", "text": "请创建菜单"}]
}),
)
.expect("ignore raw Codex user echo");
let items = read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(items, vec![user]);
}
}
@@ -0,0 +1,54 @@
use super::direct_project_history::append_direct_project_history_item_at;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Default)]
pub(crate) struct DirectProjectHistoryAccumulator {
text_by_item_id: BTreeMap<String, String>,
}
impl DirectProjectHistoryAccumulator {
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
self.text_by_item_id
.entry(item_id.to_string())
.or_default()
.push_str(delta);
}
pub(crate) fn complete_item(&mut self, item: &Value) {
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
self.text_by_item_id.remove(item_id);
}
}
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
std::mem::take(&mut self.text_by_item_id)
.into_iter()
.filter(|(_, text)| !text.is_empty())
.map(|(item_id, text)| {
serde_json::json!({
"type": "message",
"role": "assistant",
"id": item_id,
"content": [{"type": "output_text", "text": text}],
})
})
}
}
pub(crate) fn persist_direct_project_partial_items_at(
root: &Path,
accumulator: &mut DirectProjectHistoryAccumulator,
) -> Result<(), String> {
let items = accumulator.take_partial_items().collect::<Vec<_>>();
let mut first_error = None;
for item in items {
if let Err(error) = append_direct_project_history_item_at(root, &item) {
if first_error.is_none() {
first_error = Some(error);
}
}
}
first_error.map_or(Ok(()), Err)
}
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + VitePhaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -72,6 +72,21 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static
(entry.to_string(), "game-entry", "text/html"),
(format!("{prefix}style.css"), "game-style", "text/css"),
(format!("{prefix}game.js"), "game-script", "text/javascript"),
(
format!("{prefix}package.json"),
"game-package",
"application/json",
),
(
format!("{prefix}package-lock.json"),
"game-lockfile",
"application/json",
),
(
format!("{prefix}vite.config.js"),
"game-build-config",
"text/javascript",
),
]
}
@@ -2132,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
let Ok(validated_slices) = validated_art_slices(root) else {
return Vec::new();
};
if validated_slices.len() != 4 {
if validated_slices.is_empty() {
return Vec::new();
}
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
@@ -2703,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at(
asset_kind: asset_kind.to_string(),
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
slice_count: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -2806,9 +2822,9 @@ fn direct_taonier_art_package_result(
} else {
Vec::new()
};
if includes_spritesheet && slice_paths.len() != 4 {
if includes_spritesheet && slice_paths.is_empty() {
slice_warnings.push(
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
.to_string(),
);
}
@@ -3184,7 +3200,14 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource {
fn direct_codex_output_fingerprint(root: &Path) -> String {
let mut hasher = Sha256::new();
for (local_path, _, _) in direct_codex_game_outputs(root) {
let mut paths: Vec<String> = direct_codex_game_outputs(root)
.into_iter()
.map(|(p, _, _)| p)
.collect();
paths.extend(direct_npm_source_paths(root));
paths.sort();
paths.dedup();
for local_path in paths {
hasher.update(local_path.as_bytes());
hasher.update([0]);
let path = root.join(local_path);
@@ -3200,6 +3223,70 @@ fn direct_codex_output_fingerprint(root: &Path) -> String {
format!("{:x}", hasher.finalize())
}
fn direct_npm_source_paths(root: &Path) -> Vec<String> {
let base = if root.join("package.json").is_file() {
root.to_path_buf()
} else if root.join("game/package.json").is_file() {
root.join("game")
} else {
return Vec::new();
};
let mut output = Vec::new();
let mut pending = vec![(base, 0usize)];
let mut inspected = 0usize;
while let Some((directory, depth)) = pending.pop() {
if depth > 16 || inspected >= 4096 {
break;
}
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
inspected += 1;
if inspected > 4096 {
break;
}
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
if name.starts_with('.')
|| matches!(
name.as_str(),
"node_modules"
| "dist"
| "target"
| "memory"
| "exports"
| "auth.json"
| "credentials.json"
| "game-creator.config.json"
| "game-creator.config.local.json"
)
{
continue;
}
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
if kind.is_dir() {
pending.push((path, depth + 1));
} else if kind.is_file()
&& matches!(
path.extension().and_then(|e| e.to_str()),
Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json")
)
{
if let Ok(relative) = path.strip_prefix(root) {
if let Some(value) = relative.to_str() {
output.push(value.replace('\\', "/"));
}
}
}
}
}
output.sort();
output
}
fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result<std::path::PathBuf, String> {
let revision = read_game_creator_agent_runtime_project_revision(root)
.map(|value| value.revision)
@@ -3631,6 +3718,34 @@ fn sync_direct_codex_project_file_projection_at(
)?;
registered += 1;
}
for local_path in direct_npm_source_paths(root) {
if direct_codex_game_outputs(root)
.iter()
.any(|(p, _, _)| p == &local_path)
{
continue;
}
if root.join(&local_path).is_file() {
let media_type = if local_path.ends_with(".css") {
"text/css"
} else if local_path.ends_with(".json") {
"application/json"
} else if local_path.ends_with(".html") {
"text/html"
} else {
"text/javascript"
};
register_local_asset_at(
root,
&local_path,
"game-source",
media_type,
"direct-codex",
direct_codex_generated_source(),
)?;
registered += 1;
}
}
if registered == 0 {
return Err("Codex 返回后没有可登记的游戏文件".to_string());
}
@@ -4292,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
Ok(client_turn_id.to_string())
}
fn persist_direct_codex_assistant_reply_at(
root: &Path,
client_turn_id: &str,
reply: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: reply.to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:assistant"),
)
.map(|_| ())
}
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: prompt.trim().to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:user"),
)
.map(|_| ())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -4365,15 +4438,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
500,
));
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4389,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
500,
));
}
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
@@ -4411,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
}
#[cfg(test)]
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"user",
prompt,
Some(&format!("direct-codex:{client_turn_id}:user")),
)?;
append_direct_project_user_message_at(root, &item)
}
#[cfg(test)]
fn persist_direct_codex_assistant_reply_at(
root: &Path,
client_turn_id: &str,
reply: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
reply,
Some(&format!("direct-codex:{client_turn_id}:assistant")),
)?;
append_direct_project_history_item_at(root, &item)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4495,8 +4578,8 @@ mod tests {
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
.expect("the App's same-id write converges idempotently");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let conversation =
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:assistant");
let persisted = conversation
.messages
@@ -4509,7 +4592,7 @@ mod tests {
assert!(
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
.expect_err("same message identity cannot be rebound")
.contains("messageId 冲突")
.contains("item id 冲突")
);
}
@@ -4526,8 +4609,8 @@ mod tests {
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("retry reuses the same user message identity");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let conversation =
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:user");
let persisted = conversation
.messages
@@ -4540,7 +4623,7 @@ mod tests {
assert!(
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
.expect_err("same turn identity cannot be rebound")
.contains("messageId 冲突")
.contains("item id 冲突")
);
}
@@ -7343,7 +7426,7 @@ mod tests {
assert_eq!(manifest.versions.len(), 1);
assert_eq!(manifest.versions[0].version_id, "initial-1");
assert_eq!(manifest.versions[0].project_revision, 1);
assert_eq!(manifest.versions[0].resource_bindings.len(), 10);
assert_eq!(manifest.versions[0].resource_bindings.len(), 13);
for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain(
DIRECT_CODEX_SPRITESHEET_SLICE_PATHS
.iter()
@@ -7380,7 +7463,7 @@ mod tests {
.expect("project revision");
assert_eq!(revision.revision, 2);
let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest");
assert_eq!(manifest.assets.len(), 10);
assert_eq!(manifest.assets.len(), 13);
assert_eq!(
manifest
.assets
@@ -7400,7 +7483,7 @@ mod tests {
manifest.versions[1].created_reason,
GameIterationVersionCreatedReason::AgentRevision
);
assert_eq!(manifest.versions[1].resource_bindings.len(), 10);
assert_eq!(manifest.versions[1].resource_bindings.len(), 13);
let unchanged_fingerprint = direct_codex_output_fingerprint(root.path());
sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint))
@@ -8284,3 +8367,36 @@ mod tests {
);
}
}
#[cfg(test)]
mod npm_source_projection_tests {
use super::*;
#[test]
fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() {
for nested in [false, true] {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = if nested {
root.path().join("game")
} else {
root.path().to_path_buf()
};
fs::create_dir_all(source.join("src/scenes")).unwrap();
fs::create_dir_all(source.join("node_modules/demo")).unwrap();
fs::create_dir_all(source.join(".npm-cache")).unwrap();
fs::write(source.join("package.json"), "{}").unwrap();
fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap();
fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap();
fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap();
let paths = direct_npm_source_paths(root.path());
let prefix = if nested { "game/" } else { "" };
assert!(paths.contains(&format!("{prefix}src/scenes/play.ts")));
assert!(!paths
.iter()
.any(|path| path.contains("node_modules") || path.contains(".npm-cache")));
let before = direct_codex_output_fingerprint(root.path());
fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap();
assert_ne!(direct_codex_output_fingerprint(root.path()), before);
}
}
}
@@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization {
struct DirectToolBridgeActiveTurnAuthorization {
turn_id: String,
allows_regeneration: bool,
brief_sha256: Option<String>,
completed_result: Option<Value>,
resource_request_ids: BTreeMap<String, (String, String)>,
@@ -181,30 +180,23 @@ impl DirectToolBridge {
&self.url
}
/// Arm exactly one client-owned Direct turn. The raw user message is used
/// only for this synchronous decision and is never retained by the bridge.
pub(crate) fn begin_user_turn(
&self,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn(user_prompt)
/// Arm exactly one client-owned Direct turn. Codex chooses the business
/// operation through the reviewed MCP tool and arguments; the bridge only
/// binds that call to the active client turn.
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn()
}
}
impl DirectToolBridgeState {
fn begin_user_turn(
self: &Arc<Self>,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
let mut authorization = self
.turn_authorization
.lock()
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
turn_id: turn_id.clone(),
allows_regeneration,
brief_sha256: None,
completed_result: None,
resource_request_ids: BTreeMap::new(),
@@ -594,12 +586,9 @@ impl DirectToolBridgeState {
.active
.as_mut()
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
if !active.allows_regeneration {
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
}
match active.brief_sha256.as_deref() {
Some(expected) if expected != brief_sha256 => {
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
}
None => active.brief_sha256 = Some(brief_sha256.clone()),
Some(_) => {}
@@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_kind: kind.clone(),
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2736,106 +2726,20 @@ mod tests {
}
#[test]
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
for prompt in [
"继续修复布局",
"解释一下重新生成美术是什么意思",
"不要重新生成美术,只调整代码",
"别换一套美术,继续用现在这套",
"解释一下换一套美术按钮",
"是否要改变视觉风格?",
"Do not regenerate the art; keep the current package.",
"I don't want to change the visual style.",
"What does use a new art set mean?",
"文案写着“换一套美术”",
"Yesterday I said regenerate art, but today keep it.",
"Please explain how to regenerate art.",
"重新生成美术以后再说,现在只修代码",
"重做美术先不做,先改玩法",
"Regenerate the art maybe later; for now just fix the code.",
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
"Change the button label to 'please regenerate the art'; do not execute it.",
"用户之前说请重新生成美术,我只是在复述",
"Yesterday the user said please regenerate the art; I am just quoting it.",
"以后请重新生成美术,现在先改代码",
"你能不能帮我重新生成美术,顺便解释一下价格",
"请重新生成美术吗",
"请重新生成美术吗,还是只改代码",
"请重新生成美术或者只改代码",
"请重新生成美术以外的内容",
"请重新生成美术,但不要执行生成工具",
"不需要重新生成美术",
"界面上显示:请重新生成美术",
"界面标题是请重新生成美术",
"产品经理让我写请重新生成美术",
"下周请重新生成美术",
"他说«请重新生成美术»",
"Could you please regenerate the art",
"Please regenerate the art? Or only fix code.",
"Please regenerate the art except for the paid generation.",
"Please regenerate the art, but do not execute the tool.",
"Please regenerate the art, but dont execute the tool.",
"Please regenerate the art, but I don't authorize this paid generation.",
"Please regenerate the art, but I dont authorize this paid generation.",
"Please regenerate the art, but do not execute the paid tool.",
"Please regenerate the art, but never execute the paid tool.",
"Please regenerate the art, but avoid executing the paid tool.",
"Please regenerate the art, but 'do not execute the tool",
"Please regenerate the art only if it is free.",
"Please regenerate the art only after I confirm the charge.",
"Please regenerate the art, but do “not” execute the paid tool.",
"请重新生成美术,三天后再执行。",
"请重新生成美术,得到我的许可再做。",
"请重新生成美术,地面需要无缝循环。",
"Please regenerate the art, but skip the paid generation.",
"请重新生成美术【生成操作跳过】",
"请重新生成美术【仅在零元时执行】",
"Please regenerate the art “but skip the paid generation”",
"Please regenerate the art; alternatively, just fix the code.",
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
"Please regenerate the art with a clay style.",
"I don't need you to regenerate the art",
"The UI shows: please regenerate the art",
"Please regenerate the art next week",
"He said «please regenerate the art»",
] {
assert!(
!direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must fail closed: {prompt}"
);
}
for prompt in [
"请重新生成美术。",
"那就请重新生成美术!",
"换一套美术",
"Please regenerate the art!",
] {
assert!(
direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must explicitly authorize: {prompt}"
);
}
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
let root = tempfile::tempdir().expect("stable client turn root");
let state = direct_tool_bridge_state(root.path().to_path_buf());
assert!(state.begin_user_turn("请重新生成美术").is_err());
assert!(state.begin_user_turn().is_err());
let client_turn_id = "client-turn-stable-0001";
let _active_invocation =
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
.expect("client-owned stable invocation");
let ordinary_turn = state
.begin_user_turn("继续优化交互")
.expect("ordinary turn authorization state");
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
drop(ordinary_turn);
let authorized_turn = state
.begin_user_turn("请重新生成美术")
.expect("authorized regeneration turn");
let active_turn = state
.begin_user_turn()
.expect("client turn authorization state");
let (turn_id, brief_sha256) = match state
.authorize_regeneration_call("陶泥风格")
.expect("first stable regeneration call")
.expect("MCP mode selects regeneration explicitly")
{
DirectToolBridgeRegenerationCall::Execute {
turn_id,
@@ -2864,7 +2768,7 @@ mod tests {
panic!("completed stable retry must not execute a second paid call")
}
}
drop(authorized_turn);
drop(active_turn);
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
}
File diff suppressed because it is too large Load Diff
@@ -78,7 +78,7 @@ pub(crate) use draft_validation::{
validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html,
validate_playable_game_html, validate_safe_game_html_runtime,
};
pub(crate) use draft_writer::write_local_game_draft_at;
pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at};
#[allow(unused_imports)]
pub(crate) use loop_orchestration::{
emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient,
@@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) asset_kind: String,
pub(crate) asset_label: String,
pub(crate) replace_existing: bool,
pub(crate) slice_count: Option<usize>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions {
asset_kind: "game-art".to_string(),
asset_label: "AI 游戏首版美术素材".to_string(),
replace_existing: false,
slice_count: None,
}
}
}
@@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
/// Observe an accepted operation once without waiting. A single GET that
/// reports `failed` is authoritative and allows a changed retry to release
/// the old local slot; queued/running/unknown outcomes remain protected.
async fn accepted_generation_is_authoritatively_failed_once(
client: &reqwest::Client,
access: &ExternalEditorBindingAccess<'_>,
submission_payload: &serde_json::Value,
) -> Result<bool, String> {
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?;
access.validate_frozen_session()?;
let payload = tokio::time::timeout(
Duration::from_secs(3),
external_editor_json_request(
client
.get(format!(
"{}{}",
access.api_base_url(),
access.generation_status_route(&operation_id)
))
.bearer_auth(access.bearer_token()),
"查询平台图片生成任务",
),
)
.await
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
access.validate_frozen_session()?;
let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("failed") => Ok(true),
Some("queued" | "running" | "completed") => Ok(false),
Some(status) => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status}operationId={operation_id}"
)),
None => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 statusoperationId={operation_id}"
)),
}
}
pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
@@ -1714,14 +1757,8 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
// long creation request cannot reject the atlas before it is queued.
const MAX_DESCRIPTION_CHARS: usize = 200;
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
[
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
"第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
]
.into_iter()
.map(|category| {
let category =
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
@@ -1729,9 +1766,7 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
format!("{category}{CONTEXT_PREFIX}{project_context}")
})
.collect()
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
}
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
@@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
}
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?;
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
generate_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
true,
false,
&runtime_context,
)
.await
@@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.generation_prompt != generation_prompt {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) {
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.reference_resource_ids != [current_reference] {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) =
platform_art_generation_runtime_submission_payload(&state)
{
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
serde_json::json!({
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"sliceLayout": "grid-2x2",
"sliceCount": options.slice_count,
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract(
has_transparent_pixels: bool,
has_visible_pixels: bool,
) -> Result<(), String> {
if slices.len() != 4 {
return Err(format!(
"strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个",
slices.len()
));
if slices.is_empty() {
return Err("spritesheet 图集至少需要一个独立切片".to_string());
}
let resource_id = resource_id
.map(str::trim)
@@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
return Err(
"strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成"
.to_string(),
);
}
let _requested_slice_layout = spritesheet_slice_layout;
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete(
manifest: &GameCreationAppManifest,
registrations: &[PlatformArtSliceManifestRegistration],
) -> Result<bool, String> {
if registrations.len() != 4 {
if registrations.is_empty() {
return Ok(false);
}
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
@@ -7594,6 +7679,7 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "手工背景".to_string(),
replace_existing: true,
slice_count: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -9402,6 +9488,7 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "整包规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10239,6 +10326,7 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "整包背景图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10690,6 +10778,7 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11264,6 +11353,7 @@ mod canvas_generation_tests {
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
}
}
@@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at(
if prompt.is_empty() {
return Err("创作想法不能为空".to_string());
}
ensure_legacy_json_generator_project(root)?;
validate_llm_game_draft(prompt, draft)?;
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let checkpoint = create_local_project_checkpoint_at(root)?;
let timestamp = unix_timestamp();
let title = draft.title.trim();
@@ -134,3 +134,38 @@ pub(crate) fn write_local_game_draft_at(
manifest,
})
}
pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> {
if root.join("game/package.json").exists()
|| root.join("package.json").exists()
|| !root.join("game/index.html").is_file()
|| !root.join(".agent/manifest.json").is_file()
{
return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string());
}
Ok(())
}
#[cfg(test)]
mod legacy_generator_tests {
use super::*;
#[test]
fn legacy_generator_rejects_npm_before_writing() {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
fs::create_dir_all(root.path().join("game")).unwrap();
fs::create_dir_all(root.path().join(".agent")).unwrap();
fs::write(root.path().join("game/index.html"), "legacy source").unwrap();
fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path()).is_ok());
fs::write(root.path().join("game/package.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path())
.unwrap_err()
.contains("DirectProject"));
assert_eq!(
fs::read_to_string(root.path().join("game/index.html")).unwrap(),
"legacy source"
);
assert!(!root.path().join(".agent/spec.md").exists());
}
}
@@ -44,6 +44,7 @@ pub(crate) async fn run_game_creator_agent_loop_at(
project_blackboard: &str,
progress: Option<&AgentProgressEmitter<'_>>,
) -> Result<GameCreatorAgentLoopResult, String> {
ensure_legacy_json_generator_project(root)?;
let spec_path = root.join(".agent/spec.md");
let findings_path = root.join(".agent/findings.md");
let run_id = format!("game-generate-draft-{}", unix_millis());
@@ -24,6 +24,7 @@ JSON schema:
}
gameHtml 规则:
- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。
- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。
- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。
- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。
File diff suppressed because one or more lines are too long
@@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
// 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。
let _art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
let code_director = code_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
@@ -1976,7 +1969,7 @@ mod tests {
plan: Vec::new(),
actions: vec![
autonomous_initial_delegate("design-director", &[]),
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
autonomous_initial_delegate("art-director", &[]),
autonomous_initial_delegate("code-director", &[]),
],
response: String::new(),
@@ -1199,6 +1199,10 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
agent_id: &str,
required_run_id: Option<&str>,
) -> Option<AgentRuntimeToolObservation> {
// 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。
return None;
#[allow(unreachable_code)]
{
if !editor_api_key_is_configured() {
return None;
}
@@ -1258,6 +1262,7 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
}),
}
}
}
pub(in crate::agent) fn provider_retry_completion_blocker_at_locked(
@@ -294,6 +294,7 @@ pub(crate) async fn generate_local_game_draft_at(
if prompt.is_empty() {
return Err("创作想法不能为空".to_string());
}
ensure_legacy_json_generator_project(root)?;
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let short_memory = read_optional_text(&root.join("memory/session.md"))?;
@@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
editor_api_key_is_configured()
&& matches!(
task_id,
"art-director" | "design-foundation" | "art-asset-plan"
)
false
}
fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String {
@@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask
} else {
""
};
let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() {
"art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.pngassetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。"
} else {
"任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。"
};
let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。";
let verification_requirement = match task.id.as_str() {
"code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
@@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
if task.id == "art-director" {
if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
return format!(
"{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.pngassetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest"
"{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论"
);
}
return format!(
@@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
let repair_of_delegation_id =
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
let required_visual_artifact = if editor_api_key_is_configured() {
match target_agent_id.as_str() {
"design-foundation" => Some("assets/ui-prototype.png"),
"art-asset-plan" => Some("assets/art-spritesheet.png"),
_ => None,
}
} else {
None
};
let required_visual_artifact: Option<&str> = None;
if repair_of_delegation_id.is_none()
&& required_visual_artifact.is_some_and(|required| {
!expected_artifacts

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