Compare commits

..

39 Commits

Author SHA1 Message Date
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
kdletters 5db407c3dc 新增 AGC 模板库与由模板创建项目链路 (#396)
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Repository checks (push) Failing after 1m11s
Project CI / Frontend tests (push) Failing after 4m37s
Project CI / AI game creator shell web tests (push) Failing after 4m6s
Project CI / Native shell tests (push) Successful in 9m39s
Project CI / Backend tests (push) Successful in 10m12s
## 交付范围

- OSS `agc-dev` bucket 的 `templates/` 前缀作为 AGC 游戏模板库(**去掉 `agc/` 这一层**;`agc/` 继续只放客户端安装包与更新清单)。
- 模板正文改为 zip(zip 根 == AGC 项目根,如 `game/index.html`);清单补齐 `tags`、封面字段与包大小/摘要,`templateVersion` 用于判断是否需要重下。
- 客户端:Rust 模板库模块、模板库全屏页、首页模板推荐位、左侧导航入口。

## 客户端行为

- `fetch_game_template_library`:读取并校验清单(≤4 MiB),缓存到 `<app_data>/templates/index.json`;网络失败回退本机缓存并标注 `source=cache`。
- `download_game_template`:按清单下载 zip(≤512 MiB),校验字节数与 SHA-256 后解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成标记。
- `create_automatic_local_game_project_from_template`:先把模板文件铺进新项目目录,再走既有 `init_local_game_project_at` 补 `.agent` 清单与标准目录;失败删除半成品目录。
- 解压只接受普通文件与目录(拒绝绝对路径、`..`、盘符、符号链接),安装目录名由标识符白名单拼出;远端只信「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求。
- UI:搜索(空白分隔多关键词「与」)、标签/运行时筛选、仅看已下载、封面卡片与已下载徽标;「使用模板」在版本落后时先重下再建项。

## 文档与脚本

- 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;里程碑与实施计划见 `docs/project-memory/plans/`;`decision-log.md` 记录库路径、清单 schema、安装缓存目录与 CSP 约定。
- 发布脚本 `scripts/agc-template-library-publish.mjs`(`--dry-run` 校验 → 上传 → 回读校验)。
- 首页「灵感推荐」替换为模板库推荐位,删除本机灵感图目录与 `InspirationGallery.tsx`;`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于加载封面。

## 验证

- `cargo test --bin genarrative-ai-game-creator-shell template_library`:9 passed;`--ignored` 真连检查 `fetches_the_live_template_library_index` 通过。
- `npx vitest run src/features/template-library`:9 passed。
- `npm run typecheck`、`cargo fmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 均通过。
- OSS 侧匿名复核:`templates/index.json`、各 `cover.png` 与 `template.zip` 均返回 200。

## 未做 / 待确认

- 本轮未做客户端点击级人工验收(下一步单独验证并把结果补进此 PR)。
- 模板封面目前是占位标题卡,正式封面由后续模板包提供。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/396
2026-09-17 17:48:06 +08:00
lhk229 de9e8e0c7f 修复运行时默认重试次数的测试断言
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
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 (push) Successful in 7m9s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m7s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m10s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m27s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m22s
Project CI / AI game creator shell Rust crates (push) Successful in 2m5s
Project CI / Repository checks (push) Successful in 5m27s
Project CI / Frontend tests (push) Successful in 6m34s
Project CI / Native shell tests (push) Successful in 7m57s
Project CI / Backend tests (push) Successful in 10m8s
Project CI / AI game creator shell web tests (push) Has been cancelled
将恢复默认配置的重试次数断言同步为10。
补充默认配置变更时的测试核对规则。
2026-09-17 17:03:45 +08:00
lhk229 c958e1955b Merge branch 'master' into opt/design-llm-call
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
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 2/4 (push) Successful in 6m57s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m54s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 2m17s
Project CI / Frontend tests (push) Failing after 3m52s
Project CI / Repository checks (push) Failing after 3m52s
Project CI / Native shell tests (push) Successful in 8m39s
Project CI / AI game creator shell web tests (push) Failing after 4m17s
Project CI / Backend tests (push) Successful in 9m45s
2026-09-17 16:26:16 +08:00
lhk229 1b579acf47 提高客户端LLM默认重试次数到10
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
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
内置默认值 DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES 由 2 改为 10
随App分发的配置模板 game-creator.config.json 的 maxRetries 同步为 10
设置对话框草稿兜底值 maxRetries 同步为 10
2026-09-17 08:22:52 +00:00
k88936 ff84fa7b04 Merge pull request '修复推理档弹层被裁剪问题,增加 CSS 覆盖声明以确保窄布局下弹层完整显示。' (#401) from fix/effort-panel-overlay into master
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Reviewed-on: #401
2026-09-17 16:21:52 +08:00
k88936 1b56c0a586 Merge remote-tracking branch 'origin/master' into fix/effort-panel-overlay
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m32s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m14s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / Repository checks (pull_request) Successful in 4m14s
Project CI / Frontend tests (pull_request) Successful in 5m14s
Project CI / Native shell tests (pull_request) Successful in 8m38s
Project CI / Backend tests (pull_request) Successful in 9m40s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m55s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-17 15:56:56 +08:00
k88936 efdb364a52 修复推理档弹层被裁剪问题,增加 CSS 覆盖声明以确保窄布局下弹层完整显示。
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
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
2026-09-17 15:54:07 +08:00
lhk229 ebc5dfe7db 修复策划工具乱序局部修改导致的中断 (#399)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m39s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m11s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m47s
Project CI / Repository checks (push) Successful in 4m9s
Project CI / Frontend tests (push) Successful in 5m20s
Project CI / Native shell tests (push) Successful in 8m16s
Project CI / Backend tests (push) Successful in 9m49s
Project CI / AI game creator shell web tests (push) Successful in 4m4s
按原文位置排序并顺序拼接补丁,避免替换偏移失效。
新增乱序、中文与长度变化的局部修改回归测试。
同步策划 Agent 批量局部修改技术说明。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/399
2026-09-17 14:58:21 +08:00
kdletters cd9faae9b2 修复 agc_tools 媒体资源提示词上限并按 kind 暴露 (#398)
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
## 改动

- 提示词上限收敛为 `resource_edit_prompt_max_chars` 单一权威:`agc_tools` MCP 工具层、客户端受控工具桥与提交校验共用同一数字与拒绝文案,不再出现「schema 写 4000、真实上限按 kind 分」的分叉。
- `agc_create_or_derive_resource` 的 schema 逐 kind 声明 `prompt.maxLength`(background-music / sound-effect / video 与 character-animation),`prompt` 描述写明每个数字;`agc_edit_image` 继续用图片口径 32000。
- 工具桥不再用通用 4000 校验 prompt:图片编辑 4000~32000 的提示词不再被误报成「超出安全边界」。
- 源资源未登记时的错误文案补上可执行两步:先用 `agc_list_registered_assets` 选已有 localAssetId;只在项目里的文件先 `agc_list_project_files` 确认 `assetImportable=true`,再用 `agc_import_account_assets.localPaths` 登记后重试。`agc_remove_background` 复用同一条文案。
- skill 包 `agc-client-projection` 的 SKILL.md 与 projection-contract.md 写明四个上限,并说明超限要在本地收敛而不是原样重发;清单指纹同步到 `2026-08-26.18`(已合并 master 的抠图文案)。
- 前端 `resourceEditModel.ts` 补注释指向 Rust 权威函数,数字未动。
- decision-log、pitfalls 与《AI游戏创作智能体App实施计划》记录本轮口径;未改 External v1 契约 / OpenAPI、SpacetimeDB schema 与客户端 UI 行为。

## 问题

| 编号 | 问题 | 本 PR 状态 |
| --- | --- | --- |
| P1 | 背景音乐提示词真实上限 140,但 schema 只写 4000、skill 未写明,agent 写一句正常长度描述必被硬拒 | 已修复:口径单源 + 逐 kind 暴露 + skill 写明 |
| P2 | 音效 1900 与图片编辑 32000 的上限只在 Rust 内部生效,MCP 与工具桥各自硬编码或误报 | 已修复:三处共用同一函数,图片编辑不再误报安全边界 |
| P3 | 源资源未登记时只报「不属于当前项目已登记资源」,模型会原地重试 | 已修复:文案给出 `agc_list_registered_assets` 与登记工具两步动作 |
| P4 | 这两个工具此前没有端到端用例,同类漂移无法被门禁拦住 | 已补:MCP 工具层 → 真实工具桥 → 假平台的契约用例 + 两条按 kind 上限门禁 |

## 验证

- `agent::direct_tools_mcp::tests` 23 passed / 0 failed:含新增的按 kind 上限门禁、图片快速编辑契约(断言 `POST /api/editor/images/edits` 的路径、Bearer、Idempotency-Key、正文且不得回填 assetKind)、背景音乐契约(断言 `POST /api/editor/audios/background-music/generations` 的 `gptDescriptionPrompt` 与 `makeInstrumental`)、超限零桥请求、未登记源资源提示。
- `agent::direct_tool_bridge::tests` 18 passed / 7 failed;`agent::skill_pack` 4 passed;`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过(version `2026-08-26.18`)。
- AGC `typecheck`(tsc + skill-pack check + check-config)、`resourceEditModel` 6 项前端测试、`npm run check:encoding`、`npm run check:doc-index`、`cargo fmt --check`、`git diff --check` 通过。
- 那 7 条(以及 `project::resource_editor` 45 条)失败与本 PR 无关:全部是 `tempfile::tempdir()` 的 `Windows 安全对象不属于当前用户`,已 stash 改动并用基线复跑确认失败集合一致。
- 未跑整包 AGC Rust 全量分片、`npm run lint` 聚合门禁与 CI;未做任何付费生成。工具 schema 与 skill 文案都在应用内,终端用户需重新构建客户端才能看到修复效果。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: #398
2026-09-17 14:56:56 +08:00
suzmii d85622069d Merge pull request '修复 AGC 画布交互、JSON 识别与对话历史分页' (#390) from fix/agc-canvas-json into master
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m12s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m48s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m23s
Project CI / AI game creator shell Rust crates (push) Successful in 3m16s
Project CI / Frontend tests (push) Successful in 4m23s
Project CI / Repository checks (push) Successful in 5m0s
Project CI / Backend tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m38s
Project CI / Native shell tests (push) Successful in 10m45s
Reviewed-on: #390
Reviewed-by: 孔令弘 <ink29535@proton.me>
2026-09-17 14:02:12 +08:00
suzmii 036395c6c9 修复历史重读后旧消息追加到末尾的乱序
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m24s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m26s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m23s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m49s
Project CI / Repository checks (pull_request) Failing after 4m16s
Project CI / Frontend tests (pull_request) Successful in 5m46s
Project CI / Native shell tests (pull_request) Successful in 9m22s
Project CI / Backend tests (pull_request) Successful in 10m6s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m22s
区分历史回读与实时消息来源,保留运行时所有权语义
防止旧页消息在历史刷新后追加到最新回复末尾
增加重新读取历史后再次翻页的顺序和去重回归
同步对话历史规范与实施计划
2026-09-17 13:54:00 +08:00
suzmii d1f53fda4d Merge branch 'master' into fix/agc-canvas-json
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m49s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m1s
Project CI / Frontend tests (pull_request) Successful in 4m38s
Project CI / Repository checks (pull_request) Successful in 3m43s
Project CI / Backend tests (pull_request) Successful in 8m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m7s
Project CI / Native shell tests (pull_request) Successful in 25m34s
2026-09-17 13:05:33 +08:00
lhk229 9169ae0dad Merge pull request '修复前端测试状态泄漏与异步断言竞态' (#397) from codex/ci-frontend-stability-20260917 into master
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m15s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m59s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / AI game creator shell Rust crates (push) Successful in 2m21s
Project CI / Frontend tests (push) Successful in 5m22s
Project CI / Repository checks (push) Successful in 4m26s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / AI game creator shell web tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 9m49s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/397
2026-09-17 12:29:26 +08:00
lhk229 a68a9621a7 修复前端测试状态泄漏与异步断言竞态
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m4s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m43s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Successful in 4m50s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Backend tests (pull_request) Successful in 9m13s
Project CI / Native shell tests (pull_request) Successful in 8m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m0s
隔离创作主页换签网络请求并使用独立响应

恢复快捷键选择状态、持久化路由和UI编辑页宿主mock

修正虚拟树尺寸与选择时序并纳入换签Hook测试

记录全量与随机顺序测试验证方式
2026-09-17 11:56:17 +08:00
suzmii 9d374a57b1 修正运行不可用提示的过期测试契约
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m32s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 5m46s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m15s
Project CI / Native shell tests (pull_request) Successful in 9m21s
恢复运行提示与资源选择解耦的业务逻辑
更新界面源码契约测试,保留资源选中交互回归
补充运行提示及无障碍关联的验收口径
2026-09-17 11:51:14 +08:00
lhk229 d9431b51da 隔离创作主页资源预览测试缓存
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 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (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 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m6s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m16s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m38s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m56s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m6s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Frontend tests (push) Failing after 4m59s
Project CI / Repository checks (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 9m52s
Project CI / Backend tests (push) Successful in 10m33s
Project CI / AI game creator shell web tests (push) Successful in 4m13s
每个用例前后清理签名资源地址缓存,避免共享调度状态影响活动图片断言
2026-09-17 10:05:22 +08:00
suzmii 7f9dce5f79 修复画布运行不可用提示契约
恢复资源选中状态下运行提示的正确显示条件

使源码契约测试与实际行为保持一致
2026-09-17 09:48:19 +08:00
lhk229 9beb16f203 稳定CI测试并发与工作流 (#393)
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m46s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 8m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust crates (push) Successful in 2m8s
Project CI / Frontend tests (push) Failing after 4m23s
Project CI / Repository checks (push) Successful in 4m31s
Project CI / Backend tests (push) Successful in 9m14s
Project CI / Native shell tests (push) Successful in 9m5s
Project CI / AI game creator shell web tests (push) Successful in 5m19s
为Project CI增加同分支并发取消,避免旧运行覆盖新结果

将Vitest默认线程数固定为1,降低异步UI测试竞态

Reviewed-on: #393
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-17 09:18:39 +08:00
lhk229 9b6ed8a4cf 整理抠图测试导入并明确对外说明边界
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m13s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m52s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m41s
Project CI / Frontend tests (push) Successful in 5m30s
Project CI / Repository checks (push) Successful in 4m22s
Project CI / Native shell tests (push) Successful in 9m3s
Project CI / Backend tests (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 5m12s
将主站与客户端抠图测试模块的导入统一放到模块顶部
明确 provider 选择与兜底策略仅保留在内部技术方案中
2026-09-17 00:54:28 +08:00
lhk229 16deb0ad61 补齐抠图参数透传与验收
修复flat自动识别门禁并严格校验模式和颜色

保持旧请求幂等指纹并区分客户端抠图意图

同步工具说明和Skill契约并补充定向测试

记录真实BgFilter验证及本地数据库阻塞的待验收项
2026-09-17 00:54:28 +08:00
lhk229 13f56e644d 补充抠图模式调用说明
说明complex语义分割与flat纯色背景抠图

明确纯色背景优先使用flat并移除内部实现术语
2026-09-17 00:54:28 +08:00
lhk229 e08b031761 调整抠图模式参数说明 2026-09-17 00:54:28 +08:00
lhk229 70ad794b28 扩展AGC抠图客户端参数
支持complex与flat模式及auto背景色

保留旧客户端参数调用兼容性
2026-09-17 00:54:28 +08:00
lhk229 ca761e093c 接通抠图模式到BgFilter队列
透传backgroundMode和screenColor到内部worker

保留auto及省略颜色的下游识别语义
2026-09-17 00:54:28 +08:00
lhk229 a17367112f 增加主站抠图参数归一化
缺省模式兼容为complex并校验颜色格式

将auto颜色原样保留给BgFilter
2026-09-17 00:54:28 +08:00
lhk229 d7a0c3cffd 扩展主站抠图请求契约
增加backgroundMode和screenColor可选字段

同步External v1 OpenAPI抠图模式说明
2026-09-17 00:54:28 +08:00
lhk229 fa64819b6e 冻结BgFilter抠图服务契约
记录flat与complex模式及auto背景色行为

补充服务器认证头和当前运行版本
2026-09-17 00:54:28 +08:00
lhk229 f748d36d21 补充AGC抠图模式透传方案
新增主站与BgFilter模式及背景色契约

补充AGC客户端同步任务与兼容性规则

记录联调验收和发布回滚边界
2026-09-17 00:54:28 +08:00
suzmii ecaf1e8cec Merge branch 'master' into fix/agc-canvas-json
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m22s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m19s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m38s
Project CI / Frontend tests (pull_request) Failing after 3m50s
Project CI / Repository checks (pull_request) Failing after 4m18s
Project CI / Native shell tests (pull_request) Successful in 8m2s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m31s
Project CI / Backend tests (pull_request) Successful in 8m50s
2026-09-16 22:58:25 +08:00
suzmii fca3df964c Merge branch 'master' into fix/agc-canvas-json
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m15s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m31s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m21s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m48s
Project CI / Frontend tests (pull_request) Failing after 3m36s
Project CI / Repository checks (pull_request) Failing after 3m56s
Project CI / Native shell tests (pull_request) Successful in 6m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m18s
Project CI / Backend tests (pull_request) Successful in 8m18s
2026-09-16 19:29:05 +08:00
suzmii ac8a51ca79 同步master最新基线并准备画布修复PR
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
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
合入平台会话与主线测试修复
保持画布交互、JSON识别和对话历史分页修复范围不变
2026-09-16 18:59:32 +08:00
suzmii 1b010fb1f8 修复DirectProject对话历史分页卡页
聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口
工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出
隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重
补充原生和真实App回归测试,增加人工只读日志重放入口
更新B05状态、规范与PR验收记录,不提交用户原始日志
2026-09-16 18:46:47 +08:00
suzmii 825ea76b7b 同步master最新变更
合入当前主线的多窗口与配置更新
保留AGC画布交互和JSON识别修复
保留双方新增的项目排障记录并解决文档冲突
2026-09-16 17:45:06 +08:00
suzmii 910862fd06 修复AGC画布交互并识别UI设计JSON
稳定工作台与窗口标题栏状态同步,消除重复更新循环
支持画布右键平移并保留左键框选及资源拖动,完善中断清理
解耦运行不可用提示与资源选中状态
复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览
补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复
2026-09-16 17:39:23 +08:00
199 changed files with 11200 additions and 1200 deletions
+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-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
**GPT Image 2.5 新生成展示名**:
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
**系列素材图集生成**:
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
@@ -15,7 +15,7 @@
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 2,
"maxRetries": 10,
"retryBackoffMs": 500
},
"agentLlm": {}
+2
View File
@@ -57,6 +57,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
@@ -70,6 +71,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -6,6 +6,9 @@ publish = false
[features]
default = []
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
template-library-fixtures = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
@@ -13,8 +13,8 @@ Let the client derive projections from real disk changes and trusted tool result
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -14,4 +14,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.16",
"version": "2026-08-26.18",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
}
]
}
@@ -380,11 +380,16 @@ pub(crate) fn execute_design_file_tool(
}
return Err(details.join("\n"));
}
let mut updated = content.clone();
for (index, start, end) in matches.into_iter().rev() {
matches.sort_unstable_by_key(|(_, start, _)| *start);
let mut updated = String::with_capacity(content.len());
let mut cursor = 0;
for (index, start, end) in matches {
let (_, new) = &normalized[index];
updated.replace_range(start..end, new);
updated.push_str(&content[cursor..start]);
updated.push_str(new);
cursor = end;
}
updated.push_str(&content[cursor..]);
if updated == content {
return Err(format!("没有产生修改:{display}"));
}
@@ -834,6 +839,35 @@ mod tests {
assert!(!root.join("design_artifacts/notes").exists());
}
#[test]
fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() {
let temp = test_root();
let root = temp.path();
execute_design_file_tool(
root,
"write_file",
&json!({"path":"notes/design.md","content":"开头\n\n保留一\n乙乙\n保留二\n\n结尾"}),
)
.expect("write");
execute_design_file_tool(
root,
"patch_file",
&json!({
"path":"notes/design.md",
"edits":[
{"old_text":"","new_text":"新的结论"},
{"old_text":"","new_text":"扩展A"},
{"old_text":"乙乙","new_text":""}
]
}),
)
.expect("patch out of order");
assert_eq!(
fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"),
"开头\n扩展A\n保留一\n\n保留二\n新的结论\n结尾"
);
}
#[test]
fn phase_context_injects_current_skill_only() {
let resources = DesignResources::new(pack_root()).expect("pack");
@@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
read_direct_project_history_entries_filtered_at(root, None, false)
}
fn is_direct_project_chat_message(item: &Value) -> bool {
matches!(
item.get("role").and_then(Value::as_str),
Some("user" | "assistant")
) && item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
part.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
})
}
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
fn read_direct_project_history_entries_filtered_at(
root: &Path,
before_item_id: Option<&str>,
messages_only: bool,
) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
return if before_item_id.is_some() {
Err("DirectProject 历史游标对应的文件已不存在".to_string())
} else {
Ok(Vec::new())
};
}
let file = File::open(&path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
@@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
if is_direct_project_internal_context_item(&item) {
continue;
}
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
return Ok(items);
}
if messages_only && !is_direct_project_chat_message(&item) {
continue;
}
items.push((
item,
parsed
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
.unwrap_or(0),
));
}
Ok(items)
match before_item_id {
Some(item_id) => Err(format!("DirectProject 历史中不存在 item{item_id}")),
None => Ok(items),
}
}
pub(crate) fn read_direct_project_chat_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
while start > 0
&& entries[start]
.0
.get("id")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
start -= 1;
}
let timestamps = entries[start..]
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
entries
.into_iter()
.skip(start)
.map(|(item, _)| item)
.collect(),
start > 0,
timestamps,
))
}
pub(crate) fn read_direct_project_history_items_slice_at(
@@ -640,6 +713,165 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
fn write_items(root: &std::path::Path, items: &[Value]) {
let lines = items
.iter()
.enumerate()
.map(|(index, item)| {
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
.to_string()
})
.collect::<Vec<_>>();
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
}
#[test]
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
let root = init_history_project("message-pages");
let mut raw = Vec::new();
let mut expected = Vec::new();
for n in 0..44 {
let item = json!({
"id": format!("message-{n}"), "type": "message",
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
});
expected.push(item.clone());
raw.push(item);
for tool in 0..25 {
raw.push(json!({
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
"output": "工具结果不应占聊天页名额",
}));
}
}
write_items(root.path(), &raw);
let path = history_path(root.path());
let before = std::fs::read(&path).unwrap();
let (old_page, _, _) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(old_page
.iter()
.all(|item| item["type"] == "function_call_output"));
let mut cursor = None;
let mut all = Vec::new();
let mut sizes = Vec::new();
loop {
let (mut page, more, timestamps) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.unwrap();
sizes.push(page.len());
for item in &page {
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
assert_eq!(
timestamps[item["id"].as_str().unwrap()],
1000 + index as u64
);
}
let next = page
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
page.append(&mut all);
all = page;
if !more {
break;
}
assert_ne!(next, cursor);
cursor = next;
assert!(sizes.len() < 10);
}
assert_eq!(sizes, vec![20, 20, 4]);
assert_eq!(all, expected);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
let root = init_history_project("message-page-boundary");
write_items(
root.path(),
&[
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
assert_eq!(page[0]["id"], "a");
assert!(more);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[0]["id"], "u");
assert!(page[1].get("id").is_none());
assert!(!more);
assert!(
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
.is_err()
);
write_items(
root.path(),
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
assert!(page.is_empty());
assert!(!more);
}
#[test]
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
fn replay_external_chat_history_pages_without_mutating_source() {
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
let before = std::fs::read(&source).expect("read source");
let root = init_history_project("external-history-replay");
let path = history_path(root.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &before).unwrap();
let expected =
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
.expect("read messages");
let mut cursor = None;
let mut all = Vec::new();
let mut pages = 0;
loop {
let (mut items, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.expect("read page");
let next = items
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
items.append(&mut all);
all = items;
pages += 1;
if !more {
break;
}
assert!(next.is_some() && next != cursor, "cursor must advance");
assert!(pages <= expected.len() + 1, "pagination must terminate");
cursor = next;
}
assert!(
all.iter().eq(expected.iter().map(|(item, _)| item)),
"message order and content must match"
);
assert!(
std::fs::read(&source).unwrap() == before,
"source must remain unchanged"
);
eprintln!(
"history replay: messages={}, pages={pages}, users={}",
all.len(),
all.iter().filter(|item| item["role"] == "user").count()
);
}
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
pub(crate) oldest_item_id: Option<String>,
}
#[derive(Clone, Debug)]
@@ -24,7 +24,6 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100;
@@ -89,7 +88,7 @@ struct DirectToolBridgeRequest {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DirectResourceGenerationKind {
pub(crate) enum DirectResourceGenerationKind {
Image,
Video,
CharacterAnimation,
@@ -98,7 +97,7 @@ enum DirectResourceGenerationKind {
}
impl DirectResourceGenerationKind {
fn parse(value: &str) -> Result<Self, String> {
pub(crate) fn parse(value: &str) -> Result<Self, String> {
match value {
"image" => Ok(Self::Image),
"video" => Ok(Self::Video),
@@ -119,7 +118,7 @@ impl DirectResourceGenerationKind {
}
}
fn edit_kind(self) -> LocalProjectResourceEditKind {
pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind {
match self {
Self::Image => LocalProjectResourceEditKind::ImageReference,
Self::Video => LocalProjectResourceEditKind::Video,
@@ -128,6 +127,11 @@ impl DirectResourceGenerationKind {
Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic,
}
}
/// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。
pub(crate) fn prompt_max_chars(self) -> usize {
resource_edit_prompt_max_chars(&self.edit_kind())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1036,6 +1040,12 @@ fn bridge_account_asset_import_inputs(
Ok((asset_ids, local_paths))
}
/// 源资源身份不在当前项目 manifest 时的统一提示。
///
/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚:
/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。
const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。";
fn bridge_resource_generation_input(
arguments: &Value,
) -> Result<DirectResourceGenerationInput, String> {
@@ -1054,18 +1064,20 @@ fn bridge_resource_generation_input(
"sourceLocalAssetId",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
)?;
let prompt = bridge_bounded_string(
arguments,
"prompt",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS,
)?;
// prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出
// 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。
let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?;
let asset_name = bridge_bounded_string(
arguments,
"assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?;
if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 {
return Err("背景音乐提示词必须在 1..=140 字符内".to_string());
let prompt_max_chars = kind.prompt_max_chars();
if prompt.chars().count() > prompt_max_chars {
return Err(resource_edit_prompt_limit_error(
&kind.edit_kind(),
prompt_max_chars,
));
}
match (kind, mode, source_local_asset_id.as_ref()) {
(DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => {
@@ -1806,7 +1818,7 @@ async fn bridge_create_or_derive_resource(
.iter()
.find(|asset| asset.id == asset_id)
.cloned()
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())
})
.transpose()?;
let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes()));
@@ -1887,7 +1899,7 @@ async fn bridge_create_or_derive_resource(
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?;
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
@@ -1896,12 +1908,14 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
"assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?;
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
let manifest = read_existing_manifest_for_project(&state.root)?;
let source_asset = manifest
.assets
.iter()
.find(|asset| asset.id == source_asset_id)
.ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?;
.ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?;
if !source_asset.media_type.starts_with("image/") {
return Err("抠图工具只接受当前项目已登记的图片资源".to_string());
}
@@ -1920,22 +1934,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.map_err(|_| "创建抠图服务连接失败".to_string())?;
let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
})),
.json(&request_body),
)
.send()
.await
@@ -1972,6 +1998,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
}
}
fn background_removal_request_fingerprint(
source: &str,
name: &str,
mode: Option<&str>,
color: Option<&str>,
) -> String {
let mode = mode.unwrap_or("complex");
if mode == "complex" && color.is_none() {
format!("{source}\0{name}")
} else {
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
}
}
fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object();
json!({
@@ -2703,6 +2743,29 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)]
mod tests {
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", None, None),
legacy
);
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
legacy
);
let mut identities = std::collections::HashSet::new();
identities.insert(legacy.to_string());
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
let id =
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
assert_eq!(
id,
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
);
assert!(identities.insert(id));
}
}
use super::*;
use std::io::{Cursor, Read, Write};
@@ -2744,6 +2807,67 @@ mod tests {
.contains("x-genarrative-client:"));
}
/// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝,
/// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。
#[test]
fn bridge_resource_prompt_limits_follow_the_client_authority() {
for (kind, edit_kind) in [
(
"background-music",
LocalProjectResourceEditKind::BackgroundMusic,
),
("sound-effect", LocalProjectResourceEditKind::SoundEffect),
("video", LocalProjectResourceEditKind::Video),
(
"character-animation",
LocalProjectResourceEditKind::CharacterAnimation,
),
("image", LocalProjectResourceEditKind::ImageReference),
] {
let authority = resource_edit_prompt_max_chars(&edit_kind);
let mode = if matches!(
edit_kind,
LocalProjectResourceEditKind::ImageReference
| LocalProjectResourceEditKind::CharacterAnimation
) {
"derive"
} else {
"create"
};
let mut arguments = json!({
"kind": kind,
"mode": mode,
"prompt": "".repeat(authority),
"assetName": "边界名称"
});
if mode == "derive" {
arguments["sourceLocalAssetId"] = json!("registered-source");
}
bridge_resource_generation_input(&arguments)
.unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}"));
arguments["prompt"] = json!("".repeat(authority + 1));
let error = match bridge_resource_generation_input(&arguments) {
Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"),
Err(error) => error,
};
assert!(
error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)),
"{kind} 的拒绝文案必须带上真实上限与类型:{error}"
);
}
}
fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str {
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "资源编辑",
}
}
#[test]
fn bridge_argument_bounds_are_deterministic() {
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -5035,7 +5035,21 @@ pub(crate) fn read_local_project_text_preview_at(
return Err("只能读取当前项目已登记的文档资源".to_string());
}
cancellation.check()?;
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
let mut preview =
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
if normalized_path.to_ascii_lowercase().ends_with(".json") {
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
(asset.local_path == normalized_path
&& ui_editor::persistence::is_valid_ui_design_json(
&preview.content,
&manifest.project_id,
&asset.id,
))
.then(|| asset.id.clone())
});
}
cancellation.check()?;
Ok(preview)
}
#[tauri::command]
@@ -5401,19 +5415,29 @@ pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
})
})
.await
@@ -284,6 +284,7 @@ mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
@@ -323,6 +324,7 @@ use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
@@ -1561,7 +1563,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10;
fn default_game_creator_agent_mode() -> String {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
@@ -2660,7 +2662,10 @@ fn main() {
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
is_local_project_directory_non_empty,
@@ -728,7 +728,16 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> {
Ok(())
}
fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
/// 资源编辑提示词上限的**唯一口径**。
///
/// 三个调用方都必须从这里取数,禁止各自写死数字:
/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`);
/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema);
/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。
///
/// 客户端 UI 的 `resourceEditPromptMaxLength``resourceEditModel.ts`)是同一份口径的
/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。
pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize {
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => 140,
LocalProjectResourceEditKind::SoundEffect => 1_900,
@@ -739,6 +748,24 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u
}
}
/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串,
/// 保证模型看到的数字就是真实生效的数字。
pub(crate) fn resource_edit_prompt_limit_error(
edit_kind: &LocalProjectResourceEditKind,
max_chars: usize,
) -> String {
format!(
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "",
}
)
}
fn normalize_resource_edit_prompt(
edit_kind: &LocalProjectResourceEditKind,
value: &str,
@@ -746,16 +773,7 @@ fn normalize_resource_edit_prompt(
let value = value.trim();
let max_chars = resource_edit_prompt_max_chars(edit_kind);
if value.is_empty() || value.chars().count() > max_chars {
return Err(format!(
"{}资源编辑提示词必须在 1..={max_chars} 字符内",
match edit_kind {
LocalProjectResourceEditKind::BackgroundMusic => "背景音乐",
LocalProjectResourceEditKind::SoundEffect => "音效",
LocalProjectResourceEditKind::Video => "视频",
LocalProjectResourceEditKind::CharacterAnimation => "角色动画",
_ => "",
}
));
return Err(resource_edit_prompt_limit_error(edit_kind, max_chars));
}
if value
.chars()
@@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview {
pub(crate) media_type: String,
pub(crate) byte_len: u64,
pub(crate) content: String,
/// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) ui_design_asset_id: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation(
media_type: media_type.to_string(),
byte_len: content.len() as u64,
content,
ui_design_asset_id: None,
})
}
File diff suppressed because it is too large Load Diff
@@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at(
let root = Path::new(input.project_path.trim());
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(&input.asset_id, "assetId")?;
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
let document =
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
validate_document(&document, &expected_project_id, &asset_id)?;
@@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at(
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(&input.asset_id, "assetId")?;
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
let document =
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
@@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at(
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
let asset_id = required_identifier(&input.asset_id, "assetId")?;
let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
let _lock = acquire_project_write_lock(root, "ui_design.state_save")?;
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
if asset.local_path != preflight_asset.local_path {
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
}
@@ -311,6 +311,19 @@ fn ui_design_asset(
root: &Path,
expected_project_id: &str,
asset_id: &str,
) -> Result<GameCreationAppAssetManifestEntry, String> {
let asset = registered_json_asset(root, expected_project_id, asset_id)?;
// 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
}
Ok(asset)
}
fn registered_json_asset(
root: &Path,
expected_project_id: &str,
asset_id: &str,
) -> Result<GameCreationAppAssetManifestEntry, String> {
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != expected_project_id {
@@ -321,8 +334,10 @@ fn ui_design_asset(
.into_iter()
.find(|asset| asset.id == asset_id)
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
if asset.kind != "UI" || asset.media_type != "application/json" {
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
if !asset.local_path.to_ascii_lowercase().ends_with(".json")
|| !is_supported_project_text_resource(&asset.local_path, &asset.media_type)
{
return Err("目标资源不是已登记的 JSON 资产".to_string());
}
normalize_relative_path(&asset.local_path)?;
Ok(asset)
@@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
));
}
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
parse_ui_design_document(&bytes)
.map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))
}
/// 复用编辑器完整契约识别已读取的 JSON;不重读文件,也不触发恢复或任何写入。
pub(crate) fn is_valid_ui_design_json(content: &str, project_id: &str, asset_id: &str) -> bool {
parse_ui_design_document(content.as_bytes())
.and_then(|document| validate_document(&document, project_id, asset_id))
.is_ok()
}
fn parse_ui_design_document(bytes: &[u8]) -> Result<PersistedUiDesignState, String> {
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
return Err(format!(
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
));
}
let value: serde_json::Value = serde_json::from_slice(bytes)
.map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?;
let document: PersistedUiDesignState = serde_json::from_value(value.clone())
.map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?;
.map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?;
let canonical: serde_json::Value =
serde_json::from_slice(&serialize_ui_design_document(&document)?)
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
@@ -847,6 +879,170 @@ mod tests {
}
}
#[test]
fn json_preview_recognition_requires_canonical_state_and_resource_identity() {
let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap();
let content = document.to_string();
assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design"));
assert!(!is_valid_ui_design_json(
&content,
"other-project",
"design"
));
assert!(!is_valid_ui_design_json(
&content,
PROJECT_ID,
"other-asset"
));
for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] {
assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design"));
}
for (pointer, value) in [
("/schemaVersion", serde_json::json!("unknown-schema")),
("/revision", serde_json::json!(9_007_199_254_740_992u64)),
("/state/ui_trees", serde_json::json!([{}])),
] {
let mut invalid = document.clone();
*invalid.pointer_mut(pointer).unwrap() = value;
assert!(!is_valid_ui_design_json(
&invalid.to_string(),
PROJECT_ID,
"design"
));
}
let mut unknown = document.clone();
unknown["state"]["unknown"] = serde_json::json!(true);
assert!(!is_valid_ui_design_json(
&unknown.to_string(),
PROJECT_ID,
"design"
));
let invalid_state = PersistedUiDesignState {
state: state_with_unavailable_image("../outside.png"),
..empty_document(PROJECT_ID, "design")
};
assert!(!is_valid_ui_design_json(
&serde_json::to_string(&invalid_state).unwrap(),
PROJECT_ID,
"design",
));
assert!(!is_valid_ui_design_json(
&" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1),
PROJECT_ID,
"design",
));
}
#[test]
fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() {
for kind in ["UI", "ui", "ui-design", "document"] {
let (directory, asset_id) = fixture();
crate::project::mutate_manifest_at(directory.path(), |manifest| {
manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind = kind.to_string();
Ok(())
})
.unwrap();
let path = directory.path().join("ui/design.json");
let before = fs::read(&path).unwrap();
let preview = read_local_project_text_preview_at(
directory.path().to_str().unwrap(),
"ui/design.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).unwrap();
assert_eq!(
preview.ui_design_asset_id.as_deref(),
Some(asset_id.as_str())
);
assert_eq!(fs::read(&path).unwrap(), before);
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
})
.unwrap();
assert_eq!(loaded.revision, 0);
let saved = save_ui_design_state_at(input(
directory.path(),
&asset_id,
0,
state_with_unavailable_image("assets/page.png"),
))
.unwrap();
assert!(matches!(
saved,
SaveUiDesignStateResult::Saved { revision: 1, .. }
));
assert_eq!(
read_existing_manifest_for_project(directory.path())
.unwrap()
.assets
.iter()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind,
kind,
);
}
}
#[test]
fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() {
let (directory, asset_id) = fixture();
crate::project::mutate_manifest_at(directory.path(), |manifest| {
manifest
.assets
.iter_mut()
.find(|asset| asset.id == asset_id)
.unwrap()
.kind = "document".to_string();
Ok(())
})
.unwrap();
let path = directory.path().join("ui/design.json");
let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap();
for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] {
fs::write(&path, content).unwrap();
let preview = read_local_project_text_preview_at(
directory.path().to_str().unwrap(),
"ui/design.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).unwrap();
assert_eq!(preview.ui_design_asset_id, None);
assert_eq!(preview.content, content);
assert!(load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
})
.is_err());
assert!(save_ui_design_state_at(input(
directory.path(),
&asset_id,
0,
empty_document(PROJECT_ID, &asset_id).state,
))
.is_err());
assert!(
initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
fs::write(
directory.path().join("ui/unregistered.json"),
serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(),
)
.unwrap();
assert!(read_local_project_text_preview_at(
directory.path().to_str().unwrap(), "ui/unregistered.json",
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
).is_err());
}
fn state_with_unavailable_image(path: &str) -> State {
serde_json::from_value(serde_json::json!({
"ui_trees": [],
@@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
}
},
"bundle": {
@@ -0,0 +1,133 @@
{
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T03:22:43Z",
"templates": [
{
"id": "blank-2d-canvas",
"title": "空白二维画布工程",
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
"tags": [
"空白",
"起步工程",
"2d",
"canvas"
],
"runtime": "html",
"engine": "canvas",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-2d-canvas/template.zip",
"zipSizeBytes": 1534,
"zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134",
"coverKey": "templates/v1/blank-2d-canvas/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192",
"metadataKey": "templates/v1/blank-2d-canvas/template.json"
},
{
"id": "blank-3d-scene",
"title": "空白三维场景工程",
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
"tags": [
"空白",
"起步工程",
"3d",
"three.js"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-3d-scene/template.zip",
"zipSizeBytes": 1644,
"zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892",
"coverKey": "templates/v1/blank-3d-scene/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7",
"metadataKey": "templates/v1/blank-3d-scene/template.json"
},
{
"id": "blank-web",
"title": "空白网页工程",
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
"tags": [
"空白",
"起步工程",
"网页",
"原生"
],
"runtime": "html",
"engine": "none",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-web/template.zip",
"zipSizeBytes": 1212,
"zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e",
"coverKey": "templates/v1/blank-web/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
"metadataKey": "templates/v1/blank-web/template.json"
},
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
"zipSizeBytes": 8770,
"zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce",
"coverKey": "templates/v1/phaser-2d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96",
"metadataKey": "templates/v1/phaser-2d-starter/template.json"
},
{
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
"zipSizeBytes": 1697,
"zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c",
"coverKey": "templates/v1/threejs-3d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb",
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
}
]
}
+52 -27
View File
@@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
import {
type DirectThreadConsumeResult,
directThreadHistoryItemsToMessages,
directThreadHistoryPage,
type DirectThreadHistorySlice,
type DirectThreadSubscriptionBootstrap,
isDirectTurnInProgress,
prependDirectHistoryMessages,
} from './features/project-workspace/directThreadEvents';
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
@@ -625,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') {
* **** messages latch user
* replace
*
* messages "运行时拥有、且回读结果里没有"
* messages "非历史来源、运行时拥有、且回读结果里没有"
*/
function mergeLoadedConversationWithPendingRuntimeMessages(
loaded: ChatMessage[],
@@ -643,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages(
loaded.map((message) => `${message.role}\u0000${message.text}`),
);
const pending = current.filter((message) => {
if (!message.runtimeOwned) {
if (!message.runtimeOwned || message.fromHistory) {
return false;
}
if (message.messageId) {
@@ -1958,7 +1959,7 @@ export function App({
);
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
const directHistoryOldestItemIdRef = useRef<string | null>(null);
const directHistoryLoadingRef = useRef(false);
const directHistoryLoadingRef = useRef<number | null>(null);
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
null,
);
@@ -2151,6 +2152,7 @@ export function App({
function resetProjectSupervisorState() {
projectSupervisorHistoryLoadVersionRef.current += 1;
directHistoryLoadingRef.current = null;
projectSupervisorRuntimeResumeProjectPathRef.current = null;
projectSupervisorSessionIdRef.current = null;
projectSupervisorRuntimeRef.current = null;
@@ -4009,6 +4011,8 @@ export function App({
}
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
if (directCodexProductRuntime)
directHistoryLoadingRef.current = loadVersion;
try {
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
// V2 authority first; a missing V2 session returns null and preserves the
@@ -4105,6 +4109,7 @@ export function App({
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
let runtimeError = '';
let loadedDirectHistoryHasMore = false;
let loadedDirectHistoryCursor: string | null = null;
const projectConversation = directCodexProductRuntime
? (() => {
return invoke<DirectThreadHistorySlice>(
@@ -4112,16 +4117,16 @@ export function App({
{
projectPath: nextProjectPath,
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
messagesOnly: true,
},
).then((slice) => {
loadedDirectHistoryHasMore = slice.hasMore;
const page = directThreadHistoryPage(slice);
loadedDirectHistoryHasMore = page.hasMore;
loadedDirectHistoryCursor = page.cursor;
return {
path: nextProjectPath,
agentId: null,
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
messages: page.messages,
} satisfies LocalConversationResult;
});
})()
@@ -4197,7 +4202,7 @@ export function App({
const conversationMessages = mergeProjectSupervisorConversation(
resolvedProjectConversation.messages,
supervisorConversation?.messages ?? [],
);
).map((message) => ({ ...message, fromHistory: true }));
if (
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
@@ -4218,9 +4223,7 @@ export function App({
setProjectSupervisorRuntimeError(runtimeError || resumeError);
if (directCodexProductRuntime) {
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
directHistoryOldestItemIdRef.current =
conversationMessages.find((message) => message.messageId)
?.messageId ?? null;
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
}
setMessages((current) => {
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
@@ -4281,6 +4284,10 @@ export function App({
: workspaceStatus,
);
// Keep the default greeting when history is missing or blocked.
} finally {
if (directHistoryLoadingRef.current === loadVersion) {
directHistoryLoadingRef.current = null;
}
}
}
@@ -4396,6 +4403,9 @@ export function App({
setAgentRunHistoryFiles([]);
setAgentRuntimeById({});
setMessages(conversationMessages);
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
directHistoryOldestItemIdRef.current = null;
setDirectHistoryHasMore(false);
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
savedConversationProjectPathRef.current = openedProject.projectPath;
savedConversationCountRef.current = conversationMessages.length;
@@ -12480,48 +12490,63 @@ export function App({
: null;
async function showEarlierConversationMessages() {
if (hiddenConversationCount > 0) {
setConversationVisibleCount((current) =>
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
);
return;
}
if (directCodexProductRuntime && directHistoryHasMore) {
const invoke = resolveTauriInvoke();
const projectPath = localProject?.projectPath;
if (invoke && projectPath && !directHistoryLoadingRef.current) {
directHistoryLoadingRef.current = true;
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
const beforeItemId = directHistoryOldestItemIdRef.current;
directHistoryLoadingRef.current = loadVersion;
const isCurrentLoad = () =>
manifestRefreshMountedRef.current &&
localProjectPathRef.current === projectPath &&
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
try {
const slice = await invoke<DirectThreadHistorySlice>(
'read_direct_project_history_slice',
{
projectPath,
beforeItemId: directHistoryOldestItemIdRef.current,
beforeItemId,
limit: CONVERSATION_VISIBLE_STEP,
messagesOnly: true,
},
);
if (localProjectPathRef.current !== projectPath) {
if (!isCurrentLoad()) {
return;
}
const older = directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
).map((message) => ({
const page = directThreadHistoryPage(slice, beforeItemId);
const older = page.messages.map((message) => ({
role:
message.role === 'user'
? ('user' as const)
: ('assistant' as const),
text: message.content,
runtimeOwned: true,
fromHistory: true,
messageId: message.messageId,
updatedAt: message.updatedAt,
}));
setMessages((current) => [...older, ...current]);
setMessages((current) =>
prependDirectHistoryMessages(current, older),
);
setConversationVisibleCount((current) => current + older.length);
setDirectHistoryHasMore(slice.hasMore);
directHistoryOldestItemIdRef.current =
older.find((message) => message.messageId)?.messageId ??
directHistoryOldestItemIdRef.current;
setDirectHistoryHasMore(page.hasMore);
directHistoryOldestItemIdRef.current = page.cursor;
} catch (error) {
if (!isCurrentLoad()) return;
setWorkspaceStatus(
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
);
} finally {
directHistoryLoadingRef.current = false;
if (directHistoryLoadingRef.current === loadVersion) {
directHistoryLoadingRef.current = null;
}
}
}
return;
@@ -999,6 +999,8 @@ export interface ChatMessage {
agentId?: string | null;
updatedAt?: number;
runtimeOwned?: boolean;
/** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */
fromHistory?: boolean;
}
export type DesignAgentInput =
@@ -1,6 +1,12 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Copy, Minus, Square, X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
@@ -49,14 +55,21 @@ export function WindowChrome({ children }: WindowChromeProps) {
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
}, []);
const contextValue: WindowChromeContextValue = {
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
};
/**
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
*/
const contextValue = useMemo<WindowChromeContextValue>(
() => ({
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
}),
[title, setTitle, walletSlot, activeProjectRuns],
);
const [isMaximized, setIsMaximized] = useState(false);
@@ -38,6 +38,14 @@ export function useDirectActiveTurns({
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
const mountedRef = useRef(true);
const inFlightRef = useRef<Promise<void> | null>(null);
/**
* 上一次成功读取到的快照签名。
*
* 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('');
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -72,7 +80,12 @@ export function useDirectActiveTurns({
if (!mountedRef.current) {
return;
}
setActiveTurns(Array.isArray(turns) ? turns : []);
const nextTurns = Array.isArray(turns) ? turns : [];
const nextSignature = JSON.stringify(nextTurns);
if (nextSignature !== lastSnapshotSignatureRef.current) {
lastSnapshotSignatureRef.current = nextSignature;
setActiveTurns(nextTurns);
}
setSnapshotReadFailed(false);
inFlightRef.current = null;
return;
@@ -99,8 +112,10 @@ export function useDirectActiveTurns({
useEffect(() => {
if (!enabled || !invoke) {
setActiveTurns([]);
setSnapshotReadFailed(false);
lastSnapshotSignatureRef.current = '';
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
return;
}
void refreshActiveTurns();
@@ -1,4 +1,11 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { launcherNotifications } from '../../app/constants';
@@ -25,8 +32,10 @@ import {
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import TemplateLibraryView from '../../view/template-library';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
DeveloperAgentDialogs,
@@ -83,6 +92,11 @@ export function WorkspaceLauncherShell({
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
rememberRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
onProjectCreated: async (result) => {
await homeProject.enterCreatedTemplateProject(result);
},
});
const {
projectPath,
setProjectPath,
@@ -201,29 +215,47 @@ export function WorkspaceLauncherShell({
*/
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
const openProjectRef = useRef(openProject);
useLayoutEffect(() => {
openProjectRef.current = openProject;
}, [openProject]);
const openActiveProject = useCallback(
(nextProjectPath: string) => {
setProjectPath(nextProjectPath);
void openProject(nextProjectPath, 'open');
void openProjectRef.current(nextProjectPath, 'open');
},
[openProject, setProjectPath],
[setProjectPath],
);
/**
* 项目卡片面板发布给窗口标题栏的回调必须走 ref。
*
* `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份),
* 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成
* 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」
* 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。
* 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。
*/
const openActiveProjectRef = useRef(openActiveProject);
openActiveProjectRef.current = openActiveProject;
useEffect(() => {
setActiveProjectRuns({
activeTurns,
currentProjectPath: currentProjectContext?.projectPath ?? null,
readFailed: snapshotReadFailed,
onOpenProject: openActiveProject,
onOpenProject: (projectPath: string) =>
openActiveProjectRef.current(projectPath),
});
return () => setActiveProjectRuns(null);
}, [
activeTurns,
currentProjectContext?.projectPath,
openActiveProject,
setActiveProjectRuns,
snapshotReadFailed,
]);
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
useEffect(() => {
const projectPath = currentProjectContext?.projectPath ?? null;
@@ -567,6 +599,13 @@ export function WorkspaceLauncherShell({
void openProject(path, 'open');
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
templateRecommendations={templateLibrary.templates}
templateLibraryLoading={
templateLibrary.status === 'loading' ||
templateLibrary.status === 'idle'
}
templateLibraryError={templateLibrary.error}
onTemplateLibraryOpen={() => setLauncherView('template-library')}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -574,6 +613,11 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'template-library' ? (
<TemplateLibraryView
controller={templateLibrary}
onBack={() => setLauncherView('home')}
/>
) : launcherView === 'agent-chat' ? (
<DeveloperAgentPanel
controller={developerAgent}

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