From c0db2984495b4863eedd4a1fb7d81ba6b348f92d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 16 Sep 2026 11:27:07 +0800 Subject: [PATCH 01/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=80=81=E7=9B=91=E5=90=AC=20Rust=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=9B=AE=E5=BD=95=E5=AF=BC=E8=87=B4=E7=9A=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=BC=93=E6=85=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Vite 中排除 src-tauri/target,保留业务源码与共享组件热更新。 增加实际 Vite watcher 回归,验证构建目录排除及源码变更通知。 同步开发运维文档和共享排障记录,关联 Issue #324。 --- .../scripts/vite-watch.test.mjs | 113 ++++++++++++++++++ apps/ai-game-creator-shell/vite.config.ts | 3 + docs/project-memory/shared-memory/pitfalls.md | 4 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 4 files changed, 122 insertions(+) create mode 100644 apps/ai-game-creator-shell/scripts/vite-watch.test.mjs diff --git a/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs new file mode 100644 index 000000000..42fa9a5d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { createServer, loadConfigFromFile, normalizePath } from 'vite'; + +test( + 'AGC 排除 Rust 构建目录且保留源码与共享组件监听', + { timeout: 30_000 }, + async () => { + const loaded = await loadConfigFromFile( + { command: 'serve', mode: 'development' }, + fileURLToPath(new URL('../vite.config.ts', import.meta.url)), + ); + assert.ok(loaded); + assert.notEqual(loaded.config.server?.watch, null); + assert.notEqual(loaded.config.server?.hmr, false); + assert.ok( + [loaded.config.server?.watch?.ignored] + .flat() + .includes('**/src-tauri/target/**'), + ); + + const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-')); + const root = join(fixture, 'apps', 'ai-game-creator-shell'); + const source = join(root, 'src', 'main.js'); + const css = join(root, 'src', 'styles.css'); + const shared = join(fixture, 'packages', 'shared', 'src', 'component.js'); + const target = join(root, 'src-tauri', 'target'); + const artifact = join(target, 'debug', 'incremental', 'cache.bin'); + let server; + try { + for (const file of [source, css, shared, artifact]) { + await mkdir(dirname(file), { recursive: true }); + await writeFile( + file, + file === css ? 'body { color: red; }' : 'export default 1;', + ); + } + // 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具; + // 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。 + server = await createServer({ + configFile: false, + envFile: false, + root, + logLevel: 'silent', + server: { + watch: loaded.config.server?.watch, + middlewareMode: true, + hmr: false, + fs: { allow: [fixture] }, + }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const waitForWatchedFile = async (file) => { + const normalized = normalizePath(file); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ( + Object.entries(server.watcher.getWatched()).some( + ([directory, names]) => + names.some( + (name) => normalizePath(join(directory, name)) === normalized, + ), + ) + ) + return; + await delay(50); + } + assert.fail(`源码必须仍被监听:${normalized}`); + }; + await waitForWatchedFile(source); + + // 真实模块转换应将 root 外的共享源码加入监听。 + await server.transformRequest(`/@fs/${normalizePath(shared)}`); + for (const file of [source, css, shared]) { + const normalized = normalizePath(file); + await waitForWatchedFile(file); + const changed = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.watcher.off('change', onChange); + reject(new Error(`未收到源码变更:${normalized}`)); + }, 5_000); + function onChange(path) { + if (normalizePath(path) !== normalized) return; + clearTimeout(timer); + server.watcher.off('change', onChange); + resolve(); + } + server.watcher.on('change', onChange); + }); + await writeFile( + file, + file === css ? 'body { color: blue; }' : 'export default 2;', + ); + await changed; + } + const targetPath = normalizePath(target); + const targetDirectories = Object.keys(server.watcher.getWatched()) + .map(normalizePath) + .filter( + (path) => path === targetPath || path.startsWith(`${targetPath}/`), + ); + assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器'); + } finally { + await server?.close(); + await rm(fixture, { recursive: true, force: true }); + } + }, +); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 6e68153ba..fa2246ed0 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -154,6 +154,9 @@ export default defineConfig({ host: '127.0.0.1', port: 3080, strictPort: true, + watch: { + ignored: ['**/src-tauri/target/**'], + }, fs: { allow: [repoRoot], }, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e8d132d9f..99b49877b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## AGC Windows 开发态首次页面加载缓慢 + +Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。 + ## Windows 已登记生图资产未刷新 Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e036f5205..181ca4bfc 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -20,6 +20,8 @@ Stdb 发布以 root 准备文件、再切换 `spacetimedb` 用户执行时,WAS ## 本地启动 +AGC Vite 的 `server.watch.ignored` 排除 `**/src-tauri/target/**`,避免递归监听 Rust 构建产物、在 Windows 上创建大量文件监听器并拖慢首次页面加载。保留业务源码、CSS 与仓库共享组件的监听及热更新;不通过关闭 watcher 或 HMR 规避问题。监听回归使用 `node --test apps/ai-game-creator-shell/scripts/vite-watch.test.mjs`,验证构建目录被排除、应用源码及根目录外的共享源码仍能触发变更;原生窗口首绘耗时另行实测,不把监听测试耗时当作启动性能指标。 + AGC `backend` 模式与 `all` / `api-server` 一样,必须同时探测 API 和 BgFilter worker 端口,漂移后的 worker 地址同时传给 API、worker 和 readiness 检查。不能因为旧 worker 的 `/readyz` 可访问,就把新启动失败的同端口 worker 视为就绪;AGC 前端会等待完整配套后端,worker 失败可能最终表现为 Tauri 等待前端 180 秒超时。 `npm run agc` 外层启动器先执行 `agc:serve` 并等待前端与配套后端就绪,再启动 Tauri,同时清空本次 CLI 的 `beforeDevCommand`,避免重复拉起服务和把数据库发布时间计入 Tauri 的 180 秒前端等待。准备阶段最多等待 660 秒(后端门禁仍为 600 秒),退出时清理本次启动的服务树,不停止复用的服务。AGC 自动发布显式使用 `--preserve-database`,schema 冲突须人工确认迁移,不自动清空数据。 From 733a7015afd664f5d744121d805ef8f581a7a248 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:02:37 +0800 Subject: [PATCH 02/68] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E4=BF=AE=E5=A4=8D=E8=8C=83=E5=9B=B4=E4=B8=8E?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一生成占位与参考、资源卡展示和布局交互的验收要求 补充单里程碑并行实现计划和独立评审门禁 --- ...施计划】画布验收问题统一修复-2026-09-17.md | 29 ++++++++++++++++ ...里程碑】画布验收问题统一修复-2026-09-17.md | 33 +++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 +++++++ 3 files changed, 73 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md new file mode 100644 index 000000000..fd2c99bac --- /dev/null +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -0,0 +1,29 @@ +# 【实施计划】画布验收问题统一修复 + +| 字段 | 值 | +| --- | --- | +| Milestone | docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md | +| Status | awaiting-review | +| Owner | 主 Agent 集成,deepseek-flash 实现与独立评审 | + +## 修改边界与顺序 + +1. 规范独立评审通过后,在同一里程碑内并行三个 worktree。 +2. 生成工作区负责生成面板、任务占位、引用、原生命令必要最小变更和工作台入口接线。 +3. 展示工作区负责卡片名称/文档图标、预览表现与对应测试,不修改工作台入口及画布手势。 +4. 布局工作区负责共享画布多选拖动、布局保存/整理/撤销及测试;工作台入口仅允许布局接线局部变更,集成时人工检查与生成工作区的冲突。 +5. 每个实现者先自审、运行定向验证、本地中文提交并报告文件列表;主 Agent 逐项核对后集成,再由独立 Agent 先写评审计划、理解全流程、由局部到整体只读 review。 +6. 主 Agent 复核问题并指派有效问题返修,重新验证后交付。远程写入另行确认。 + +## 验证命令 + +- `npm test -- <相关测试文件>` +- `npm run typecheck --workspace @genarrative/ai-game-creator-shell` +- `npm run check:encoding` +- `npm run check:doc-index` +- `git diff --check` +- 原生契约变更时补对应 Rust 定向测试;真实 Provider 调用前单独核对费用和环境。 + +## 风险与回滚点 + +生成结果登记、持久化布局及手势共享组件是主要交叉风险;独立分支保留可回滚提交,不覆盖原有工作区。依赖复用需避免 workspace 包指向旧工作区;不能因测试通过而跳过实际解析路径核对。文档临时计划在全部验收后清理。 diff --git a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md new file mode 100644 index 000000000..de28e0c27 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md @@ -0,0 +1,33 @@ +# 【里程碑】画布验收问题统一修复 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-17 | +| Parent Spec | docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | + +## 目标与范围 + +作为一个里程碑统一交付:生成参考、占位生成、资源名称、文档卡、多选移动、当前栏目整理及撤销。内部可用独立 worktree 并行,统一集成验收。 + +## 非目标 + +不做拖拽对话引用、聊天复制、远程写入和无关重构。不修改数据库 schema。 + +## 前置条件 + +主规范“资源画布生成、展示与布局合同”通过独立评审;保留原工作区已有未提交文件。以当前分支 d85622069 为实现基线。 + +## 验收标准 + +- [ ] 空素材项目可打开生成占位和面板,必要规范前置在提交时验证。 +- [ ] 图片引用传递真实资源身份;任务失败可重试,成功回写最新占位位置。 +- [ ] 名称统一显示,文档卡无无效正文,详情与 UI JSON 能力无回归。 +- [ ] 当前栏目全部整理且可撤销,其他栏目和筛选集合语义正确。 +- [ ] 多选移动保持相对位置,保存与撤销为一次操作。 +- [ ] 独立 review 的有效阻断问题修复并验证。 + +## 证据要求 + +定向测试、AGC 类型检查、编码/文档索引及 diff 检查;尽可能运行 UI smoke。真实客户端/Provider 未验证必须单独列出。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4c39954dc..e50ed2adc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,16 @@ # AI 游戏创作智能体 App 实施计划 +## 资源画布生成、展示与布局合同 + +- 生成工具点击后先在当前栏目创建临时占位卡,并以卡片为锚点展示独立生成浮层;占位不登记为正式素材、不进入 Agent 可引用资源集。上传仍沿用文件选择,不创建虚假生成任务。关闭编辑浮层不应丢失正在执行的任务;切换项目不得将旧项目结果或草稿写入新项目。 +- 图片生成支持从现有素材选择器添加真实参考;引用携带稳定资源身份并经既有原生权限、归属与类型校验传至生成链路,不能仅拼接名称。缺少规范图不阻止打开面板;确有规范前置的操作必须在提交前满足要求,不能绕过后端校验。用户可通过现有工具栏先生成规范图。 +- 占位可移动。提交复用正式生成任务、幂等与结果登记链路;成功结果使用占位最新位置,失败保留输入与引用供重试。已受理但响应不确定时先对账,不能无条件再次发起付费生成。关闭、删除占位和后台任务的行为需保持既有任务所有权,不把隐藏展示当作取消任务。 +- 所有资源卡显示正式素材名称(Agent 生成的 assetName 或用户重命名),没有名称时才使用既有文件名兜底;长名称省略但可查看完整名称。文档卡以居中图标呈现,不显示无效正文片段;独立详情保留原文预览、UI JSON 识别、权限及读取预算。 +- “整理画布”显式重排当前栏目全部素材,包括手动坐标;筛选不缩小整理集合,其他栏目不变。重排作为一次可撤销操作保存,失败继续使用现有写队列及冲突处理,不伪报保存成功。 +- 框选多个素材后拖动任一已选卡,按统一位移移动整个选择集并保持相对位置;拖动未选卡保留单选语义。松手统一提交,整次操作可一次撤销;缩放坐标、指针取消、窗口失焦、保存失败和项目切换不得导致选择丢失或布局串写。 +- 本合同不包含拖入对话批量 @、复制聊天引用、历史替换交互、SpacetimeDB schema 或新的远程公开 API。共享表现与交互优先扩展公共组件,正式资源状态仍由宿主/原生链路维护。 +- 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 From 87aed0b76541cc8e50b0c4f91a2ddbd41647fdf2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:06:57 +0800 Subject: [PATCH 03/68] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确参考图片入参、账号绑定与恢复校验 固定栏目整理撤销、多选范围与素材名称来源 --- .../plans/【实施计划】画布验收问题统一修复-2026-09-17.md | 7 +++++++ .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md index fd2c99bac..9a987b264 100644 --- a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -15,6 +15,13 @@ 5. 每个实现者先自审、运行定向验证、本地中文提交并报告文件列表;主 Agent 逐项核对后集成,再由独立 Agent 先写评审计划、理解全流程、由局部到整体只读 review。 6. 主 Agent 复核问题并指派有效问题返修,重新验证后交付。远程写入另行确认。 +## 评审补充与接缝所有权 + +- 参考选择复用当前资源引用选择组件,原生命令新增可选引用 ID 列表,解析为当前项目已登记图片,复用当前账号 external editor binding;平台请求消费现有 `referenceImageSrcs`,不修改远端 API 合同。扩展 durable 请求指纹、快照及恢复校验以包含引用,补 Rust 测试;对账复用 `list_local_project_asset_generation_tasks`(以源码实际命名为准)及 manifest,不新造账本。 +- 主规范已固定草稿/任务身份、切换恢复、整理集合、历史粒度、手动标记及名称字段来源。真实 Provider smoke 有费用与环境前置,不默认触发付费生成。 +- index.tsx 的 ResourceCard 组件体、名称/图标相关 import 属展示实现者;生成 action/draft/task/settlement 与占位 state 属生成实现者;selectedResourceIds、resourceCanvasHistory、drag handlers 和整理 handler 属布局实现者。不得整文件格式化或跨界重构;共享 import 冲突由主 Agent 集成。 +- 布局实现者提供支持批量坐标及手动标记的保存入口;生成实现者仅使用现有单位置保存接口做完成落点,避免同时修改布局 hook。 + ## 验证命令 - `npm test -- <相关测试文件>` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e50ed2adc..dd2d13dbd 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -9,6 +9,10 @@ - “整理画布”显式重排当前栏目全部素材,包括手动坐标;筛选不缩小整理集合,其他栏目不变。重排作为一次可撤销操作保存,失败继续使用现有写队列及冲突处理,不伪报保存成功。 - 框选多个素材后拖动任一已选卡,按统一位移移动整个选择集并保持相对位置;拖动未选卡保留单选语义。松手统一提交,整次操作可一次撤销;缩放坐标、指针取消、窗口失焦、保存失败和项目切换不得导致选择丢失或布局串写。 - 本合同不包含拖入对话批量 @、复制聊天引用、历史替换交互、SpacetimeDB schema 或新的远程公开 API。共享表现与交互优先扩展公共组件,正式资源状态仍由宿主/原生链路维护。 +- 参考选择范围为同一项目已登记图片,可跨栏目、多选,最多 8 张;复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不能伪装支持任意多参考。不得降级成纯提示词。 +- 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 +- 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 +- 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 - 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。 ## 资源画布交互与工作台状态同步 From 70513cf049bcb0b15fee8b075718bd3d88b1a5bc Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:09:14 +0800 Subject: [PATCH 04/68] =?UTF-8?q?=E6=8C=89=E8=A7=84=E8=8C=83=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=9B=BA=E5=AE=9A=E5=8F=82=E8=80=83=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E5=B9=B6=E6=94=BE=E8=A1=8C=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通生成最多五张参考,图集拒绝额外参考 复用现有引用快照并核验恢复校验,明确里程碑验收边界 --- .../plans/【实施计划】画布验收问题统一修复-2026-09-17.md | 4 ++-- .../plans/【里程碑】画布验收问题统一修复-2026-09-17.md | 4 +++- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md index 9a987b264..abc6934bd 100644 --- a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md | -| Status | awaiting-review | +| Status | ready | | Owner | 主 Agent 集成,deepseek-flash 实现与独立评审 | ## 修改边界与顺序 @@ -17,7 +17,7 @@ ## 评审补充与接缝所有权 -- 参考选择复用当前资源引用选择组件,原生命令新增可选引用 ID 列表,解析为当前项目已登记图片,复用当前账号 external editor binding;平台请求消费现有 `referenceImageSrcs`,不修改远端 API 合同。扩展 durable 请求指纹、快照及恢复校验以包含引用,补 Rust 测试;对账复用 `list_local_project_asset_generation_tasks`(以源码实际命名为准)及 manifest,不新造账本。 +- 参考选择复用当前资源引用选择组件,原生命令新增可选引用 ID 列表,解析为当前项目已登记图片,复用当前账号 external editor binding;平台请求消费现有 `referenceImageSrcs`,不修改远端 API 合同。复用 durable 已有引用快照及指纹,核对并修复仍硬编码为空或单引用的恢复校验,不无端扩展账本 schema,补 Rust 测试;对账复用 `list_local_project_asset_generations`(以源码实际命名为准)及 manifest,不新造账本。 - 主规范已固定草稿/任务身份、切换恢复、整理集合、历史粒度、手动标记及名称字段来源。真实 Provider smoke 有费用与环境前置,不默认触发付费生成。 - index.tsx 的 ResourceCard 组件体、名称/图标相关 import 属展示实现者;生成 action/draft/task/settlement 与占位 state 属生成实现者;selectedResourceIds、resourceCanvasHistory、drag handlers 和整理 handler 属布局实现者。不得整文件格式化或跨界重构;共享 import 冲突由主 Agent 集成。 - 布局实现者提供支持批量坐标及手动标记的保存入口;生成实现者仅使用现有单位置保存接口做完成落点,避免同时修改布局 hook。 diff --git a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md index de28e0c27..f10b1df14 100644 --- a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | proposed | +| Status | approved | | Date | 2026-09-17 | | Parent Spec | docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | @@ -31,3 +31,5 @@ ## 证据要求 定向测试、AGC 类型检查、编码/文档索引及 diff 检查;尽可能运行 UI smoke。真实客户端/Provider 未验证必须单独列出。 + +- [ ] 普通生成总参考不超过 5 张,图集不接受额外参考;重试复用占位,重开不恢复未持久化占位位置。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dd2d13dbd..fb67c02db 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -9,7 +9,7 @@ - “整理画布”显式重排当前栏目全部素材,包括手动坐标;筛选不缩小整理集合,其他栏目不变。重排作为一次可撤销操作保存,失败继续使用现有写队列及冲突处理,不伪报保存成功。 - 框选多个素材后拖动任一已选卡,按统一位移移动整个选择集并保持相对位置;拖动未选卡保留单选语义。松手统一提交,整次操作可一次撤销;缩放坐标、指针取消、窗口失焦、保存失败和项目切换不得导致选择丢失或布局串写。 - 本合同不包含拖入对话批量 @、复制聊天引用、历史替换交互、SpacetimeDB schema 或新的远程公开 API。共享表现与交互优先扩展公共组件,正式资源状态仍由宿主/原生链路维护。 -- 参考选择范围为同一项目已登记图片,可跨栏目、多选,最多 8 张;复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不能伪装支持任意多参考。不得降级成纯提示词。 +- 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 From 91b65f94ca21bba4051f64f5d262c72989c58747 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:18:17 +0800 Subject: [PATCH 05/68] =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=A0=91=E6=B5=8B=E8=AF=95=E8=AF=BB=E5=8F=96=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=BB=84=E4=BB=B6=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐共享包测试别名以避免复用依赖指向其他工作树 记录工作树依赖复用的解析检查要求 --- .../shared-memory/development-workflow.md | 2 ++ vitest.config.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 36fbbe575..06a2ed208 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -24,6 +24,8 @@ ## 开始前 +- worktree 复用 `node_modules` 时,测试与构建的 workspace alias 必须指向当前工作树源码,不能经依赖软链接读取另一工作树的共享包。遇到仅 worktree 出现的 JSX 编译错误时先核对解析路径,不用给组件补全局变量来掩盖错误来源。 + - 运行 `git status --short`,保留用户已有的未提交修改;不要在共享工作树中使用破坏性 Git 命令。 - 复杂任务先读 `AGENTS.md`、`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`、`docs/README.md` 和对应专题。 - 需要完整 SDD 的任务先确认主规范位置和验收证据,再创建 `docs/project-memory/plans/` 下的里程碑规范与实现计划;计划完成、取消或合并后删除。 diff --git a/vitest.config.ts b/vitest.config.ts index 4d6d56888..e872c8c64 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,26 @@ export default defineConfig({ }, // Keep shared components' application-root imports resolvable in tests. { find: '@', replacement: path.resolve(__dirname, '.') }, + // worktree 复用依赖时,共享包仍须解析到当前检出的源码。 + { + find: '@genarrative/shared/components/account', + replacement: path.resolve( + __dirname, + 'packages/shared/src/components/account.ts', + ), + }, + { + find: '@genarrative/shared/components', + replacement: path.resolve(__dirname, 'packages/shared/src/components'), + }, + { + find: '@genarrative/shared/lib', + replacement: path.resolve(__dirname, 'packages/shared/src/lib'), + }, + { + find: /^@genarrative\/shared$/, + replacement: path.resolve(__dirname, 'packages/shared/src/index.ts'), + }, { find: '@genarrative/image-canvas-core', replacement: path.resolve( From ef982fdb786270b94065f35204bc985f02a286cd Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:59:48 +0800 Subject: [PATCH 06/68] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E4=B8=8E=E5=89=8D=E7=AB=AF=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E5=AE=9E=E7=8E=B0=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用独立工作树处理原生引用,固定引用资产ID接缝 --- .../plans/【实施计划】画布验收问题统一修复-2026-09-17.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md index abc6934bd..ed6ba877f 100644 --- a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -8,8 +8,8 @@ ## 修改边界与顺序 -1. 规范独立评审通过后,在同一里程碑内并行三个 worktree。 -2. 生成工作区负责生成面板、任务占位、引用、原生命令必要最小变更和工作台入口接线。 +1. 规范独立评审通过后,在同一里程碑内并行四个 worktree。 +2. 生成工作区负责生成面板、任务占位、引用和工作台入口接线;原生引用工作区独立负责 Rust 入参、账号绑定、生成请求与恢复校验。两者以可选 `referenceAssetIds` / `reference_asset_ids`(当前 manifest 的 asset.id 列表)作为接缝,避免 UI 等待原生实现。 3. 展示工作区负责卡片名称/文档图标、预览表现与对应测试,不修改工作台入口及画布手势。 4. 布局工作区负责共享画布多选拖动、布局保存/整理/撤销及测试;工作台入口仅允许布局接线局部变更,集成时人工检查与生成工作区的冲突。 5. 每个实现者先自审、运行定向验证、本地中文提交并报告文件列表;主 Agent 逐项核对后集成,再由独立 Agent 先写评审计划、理解全流程、由局部到整体只读 review。 From a7b2b0e23b88be4cb73c58696e9156cde063502e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:57:21 +0800 Subject: [PATCH 07/68] =?UTF-8?q?=E7=94=BB=E5=B8=83=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=94=AF=E6=8C=81=E7=9C=9F=E5=AE=9E=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增参考图模型:候选为当前项目已登记图片,用户参考上限普通生成 5 张、带规范前置 4 张、图集为 0 生成浮层复用资源引用输入组件,提示词里的 @ 引用直接产出当前 manifest 资产 ID 作为参考 生成任务与队列新增 referenceAssetIds,派发时以 referenceAssetIds 交给原生侧解析账号绑定 图集入口不呈现参考选择器,超限在提交前给出原因并挡住提交,不做静默截断 补充参考模型、面板接线与队列派发的定向测试 --- ...ResourceCanvasAssetGenerationPanelView.tsx | 139 +++++++- .../resourceCanvasAssetGenerationQueue.ts | 1 + ...urceCanvasAssetGenerationReferenceModel.ts | 121 +++++++ .../resourceCanvasAssetGenerationTaskModel.ts | 11 + .../src/view/project-development/index.tsx | 12 + ...resourceCanvasAssetGenerationQueue.test.ts | 48 +++ ...ceCanvasAssetGenerationReferences.test.tsx | 299 ++++++++++++++++++ 7 files changed, 617 insertions(+), 14 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 5c36c3d56..ba079eb25 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -4,14 +4,30 @@ import { type FormEvent, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import type { + GameCreationAppAssetManifestEntry, + GameIterationVersion, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { ThemedModal } from '../../components/modal/ThemedModal'; +import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput'; +import type { + ChatComposerDraft, + ChatReference, +} from '../project-workspace/resourceReferences'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; import { RESOURCE_CANVAS_ASSET_ASPECT_RATIOS, RESOURCE_CANVAS_ASSET_IMAGE_SIZES, type ResourceCanvasAssetToolAction, } from './resourceCanvasBottomToolbarModel'; +import { + resourceCanvasAssetGenerationAcceptsReferences, + resourceCanvasAssetGenerationReferenceAssets, + resourceCanvasAssetGenerationReferenceError, + resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationUserReferenceLimit, +} from './resourceCanvasAssetGenerationReferenceModel'; import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; export type ResourceCanvasAssetGenerationSubmitInput = { @@ -20,6 +36,14 @@ export type ResourceCanvasAssetGenerationSubmitInput = { assetName: string; aspectRatio: string; imageSize: string; + /** + * 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。 + * + * 宿主从这里取 `resourceId`(**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧; + * 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里 + * 历史账号的远端 ID。 + */ + references: ChatReference[]; }; /** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ @@ -28,6 +52,8 @@ export type ResourceCanvasAssetGenerationPanelDraft = { assetName: string; aspectRatio: string; imageSize: string; + /** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */ + references: ChatReference[]; }; export type ResourceCanvasAssetGenerationPanelViewProps = { @@ -40,6 +66,12 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { draft?: ResourceCanvasAssetGenerationPanelDraft; /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ error?: string | null; + /** 当前项目的 manifest 资产:参考选择的候选集由它收口到本项目的已登记图片。 */ + assets?: readonly GameCreationAppAssetManifestEntry[]; + /** `@` 引用选择器需要项目路径来登记资源预览,与快速编辑走同一条链路。 */ + projectPath?: string; + versions?: GameIterationVersion[]; + activeVersionId?: string | null; /** * 提交回调:**同步返回**,面板不等它的结果。 * @@ -73,10 +105,17 @@ export function ResourceCanvasAssetGenerationPanelView({ action, draft, error: initialError, + assets, + projectPath, + versions, + activeVersionId, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [references, setReferences] = useState( + draft?.references ?? [], + ); const [assetName, setAssetName] = useState( draft?.assetName ?? action.assetName, ); @@ -90,13 +129,50 @@ export function ResourceCanvasAssetGenerationPanelView({ // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; + /** + * 参考选择只对有真实参考能力的入口呈现。 + * + * 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器, + * 原生侧同样拒绝额外参考(不是静默丢弃)。 + */ + const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action); + const referenceLimit = + resourceCanvasAssetGenerationUserReferenceLimit(action); + const referenceAssets = resourceCanvasAssetGenerationReferenceAssets( + assets ?? [], + ); + const referenceAssetIds = + resourceCanvasAssetGenerationReferenceIds(references); + const referenceError = resourceCanvasAssetGenerationReferenceError({ + action, + referenceCount: referenceAssetIds.length, + }); + const promptTooLong = prompt.trim().length > promptMaxLength; + const promptTooLongError = promptTooLong + ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` + : null; + const canSubmit = + prompt.trim().length > 0 && + assetName.trim().length > 0 && + !referenceError && + !promptTooLong; + const shownError = error ?? promptTooLongError ?? referenceError; + const applyDraft = (next: ChatComposerDraft) => { + setPrompt(next.text); + setReferences(next.references); + }; function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName) { + // 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。 + if ( + !normalizedPrompt || + !normalizedAssetName || + referenceError || + promptTooLong + ) { return; } setError(null); @@ -108,6 +184,7 @@ export function ResourceCanvasAssetGenerationPanelView({ assetName: normalizedAssetName, aspectRatio, imageSize, + references, }); onClose(); } @@ -143,16 +220,40 @@ export function ResourceCanvasAssetGenerationPanelView({ {action.adjustableDimensions ? (
@@ -197,9 +298,19 @@ export function ResourceCanvasAssetGenerationPanelView({ prompt={prompt} applyPrompt={setPrompt} /> - {error ? ( + {referenceEnabled && referenceLimit > 0 ? ( +

+ {`参考图 ${referenceAssetIds.length}/${referenceLimit}`} +

+ ) : null} + {shownError ? (

- {error} + {shownError}

) : null}
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 87e724bd6..2b0ac9634 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -152,6 +152,7 @@ export function createResourceCanvasAssetGenerationQueue( aspectRatio: task.aspectRatio, imageSize: task.imageSize, assetName: task.assetName, + referenceAssetIds: task.referenceAssetIds, outputPath: task.outputPath, })) as LocalProjectAssetGenerationTaskRecord; let started: LocalProjectAssetGenerationTaskRecord; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts new file mode 100644 index 000000000..0af767737 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts @@ -0,0 +1,121 @@ +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { ChatReference } from '../project-workspace/resourceReferences'; +import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel'; + +/** + * 参考图上限:一次生成请求里 `referenceImageSrcs` 的总数,**含**规范图。 + * + * 与 Rust `PLATFORM_ART_MAX_REFERENCE_IMAGES` 同口径:面板负责在提交前挡住超限,原生侧再挡一次, + * 两侧都不做截断(截断就是静默丢弃用户的选择)。 + */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES = 5; + +/** 没有规范图前置时的用户参考上限。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES = 5; + +/** 有规范图前置时:权威规范图自己占掉一张,用户参考最多四张。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC = 4; + +/** + * 不接受用户参考的生成类型。 + * + * 目前只有 `art-spritesheet`(图集)按合同只接受单张规范引用:这类入口不呈现用户参考选择器, + * 原生提交也会显式拒绝额外参考(不是静默丢弃)。 + * + * 其它入口一律支持用户参考,包括 `icon-spec`(图标规范):它虽然产出权威规范图,但生成时同样 + * 可以带参考图,上限与普通生成一致。 + */ +const RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS: readonly string[] = [ + 'art-spritesheet', +]; + +export function resourceCanvasAssetGenerationAcceptsReferences( + action: ResourceCanvasAssetToolAction, +): boolean { + return !RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS.includes( + action.assetKind, + ); +} + +/** + * 该入口允许用户选几张参考。 + * + * `0` 表示不呈现参考选择器;其余值与原生侧的用户参考上限一致。 + */ +export function resourceCanvasAssetGenerationUserReferenceLimit( + action: ResourceCanvasAssetToolAction, +): number { + if (!resourceCanvasAssetGenerationAcceptsReferences(action)) { + return 0; + } + return action.requiresIconSpecReference + ? RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + : RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES; +} + +/** + * 参考选择的候选集:**当前项目**已登记的图片。 + * + * 直接吃当前 manifest 的 `assets`,所以候选天然限定在同一项目内;再按媒体类型收口到图片, + * 文档、音视频、字体、代码与未落盘的占位都不进候选。清单里没有 `localPath` 的记录(例如只存在于 + * 远端画布、本地还没有文件的条目)同样排除——原生侧要读本地正式文件才能按当前账号重新上传绑定。 + */ +export function resourceCanvasAssetGenerationReferenceAssets( + assets: readonly GameCreationAppAssetManifestEntry[], +): GameCreationAppAssetManifestEntry[] { + return assets.filter( + (asset) => + asset.mediaType.startsWith('image/') && + asset.localPath.trim().length > 0 && + !asset.localPath.startsWith('.agent/'), + ); +} + +/** + * 从草稿里的引用列表取出本次生成的参考资源 ID。 + * + * 只认资源引用(运行态区域引用不是素材);顺序即用户选择顺序,重复选择同一张按一次算。 + * 这些 ID 是**当前项目 manifest 的资产 ID**,不是本地路径,也不是历史远端 ID:原生侧据此读本地 + * 正式文件并按当前账号重新建立远端绑定。 + */ +export function resourceCanvasAssetGenerationReferenceIds( + references: readonly ChatReference[], +): string[] { + const ids: string[] = []; + for (const reference of references) { + if (reference.type !== 'resource') { + continue; + } + const resourceId = reference.resourceId.trim(); + if (!resourceId || ids.includes(resourceId)) { + continue; + } + ids.push(resourceId); + } + return ids; +} + +/** + * 提交前对参考数量的判据。 + * + * 超限时给一条能照着做的原因,而不是把多出来的引用悄悄丢掉:面板据此禁用提交, + * 原生侧仍按同一上限再校验一次。 + */ +export function resourceCanvasAssetGenerationReferenceError({ + action, + referenceCount, +}: { + action: ResourceCanvasAssetToolAction; + referenceCount: number; +}): string | null { + if (!resourceCanvasAssetGenerationAcceptsReferences(action)) { + return null; + } + const limit = resourceCanvasAssetGenerationUserReferenceLimit(action); + if (referenceCount <= limit) { + return null; + } + return action.requiresIconSpecReference + ? `已选 ${referenceCount} 张参考图;该入口会带上权威规范图,用户参考最多 ${limit} 张` + : `已选 ${referenceCount} 张参考图;最多 ${limit} 张`; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index eaa6b87e7..1bb3b32c9 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -41,6 +41,14 @@ export type ResourceCanvasAssetGenerationTask = { prompt: string; aspectRatio: string; imageSize: string; + /** + * 本次生成携带的参考图(当前项目 manifest 的资产 ID,按选择顺序去重)。 + * + * 派发时随 `referenceAssetIds` 交给原生侧:它读本地正式文件并按当前账号重新建立远端绑定, + * 前端不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。恢复出来的历史任务(账本里 + * 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。 + */ + referenceAssetIds: string[]; outputPath: string | null; projectId: string; /** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */ @@ -148,6 +156,7 @@ export function createResourceCanvasAssetGenerationTask(input: { assetName: string; aspectRatio: string; imageSize: string; + referenceAssetIds?: readonly string[]; outputPath: string | null; projectId: string; nowMillis: number; @@ -161,6 +170,7 @@ export function createResourceCanvasAssetGenerationTask(input: { prompt: input.prompt, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + referenceAssetIds: [...(input.referenceAssetIds ?? [])], outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -191,6 +201,7 @@ export function restoreResourceCanvasAssetGenerationTask( prompt: '', aspectRatio: '', imageSize: '', + referenceAssetIds: [], outputPath: null, projectId: record.projectId, dispatched: true, diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..d713a603a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -120,6 +120,7 @@ import { ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -7261,6 +7262,11 @@ export default function ProjectDevelopmentView({ assetName: input.assetName, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + // 参考图只传**当前 manifest 的资产 ID**:原生侧据此读本地正式文件并按当前账号重新 + // 建立远端绑定,前端既不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。 + referenceAssetIds: resourceCanvasAssetGenerationReferenceIds( + input.references, + ), outputPath: resourceCanvasAssetGenerationOutputPath( action, context.hasIconSpecReference, @@ -7276,6 +7282,7 @@ export default function ProjectDevelopmentView({ assetName: input.assetName, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + references: input.references, }, dispatchedImmediately, }; @@ -8849,6 +8856,11 @@ export default function ProjectDevelopmentView({ : 'fresh' }`} action={resourceAssetGenerationAction} + // 参考选择的候选集来自当前项目 manifest,收口到已登记图片;与快速编辑同一份 `@` 链路。 + assets={manifest.assets} + projectPath={projectPath} + versions={projectVersions} + activeVersionId={activeVersionId} draft={ resourceAssetGenerationPanelReopen?.actionId === resourceAssetGenerationAction.id diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 908aa2669..de46eefd5 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -79,6 +79,31 @@ describe('生成任务模型', () => { RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, ); expect(task.restored).toBe(false); + // 没有选择参考图时就是空列表:原生侧按「没有参考」处理,前端不编造引用。 + expect(task.referenceAssetIds).toEqual([]); + }); + + test('参考图资产 ID 随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { + const task = createResourceCanvasAssetGenerationTask({ + taskId: 'task-ref', + action: uiPrototypeAction, + prompt: '主界面', + assetName: 'UI', + aspectRatio: '16:9', + imageSize: '1K', + referenceAssetIds: ['asset-a', 'asset-b', 'asset-a'], + outputPath: null, + projectId: 'project-1', + nowMillis: 10, + }); + expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b', 'asset-a']); + + const restored = applyLocalProjectAssetGenerationRecords( + [], + [record('task-ref', 'completed')], + ); + expect(restored).toHaveLength(1); + expect(restored[0]?.referenceAssetIds).toEqual([]); }); test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => { @@ -348,6 +373,29 @@ describe('本地排队驱动器', () => { ).toBe('asset-task-b'); }); + test('派发把参考图资产 ID 原样交给原生侧', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) { + api.advance('task-ref', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + await queue.submit({ + ...localTask('task-ref', 10), + referenceAssetIds: ['asset-a', 'asset-b'], + }); + + const startCall = harness.invoke.mock.calls.find( + ([command]) => command === 'start_local_project_asset_generation', + ); + expect(startCall?.[1]).toMatchObject({ + taskId: 'task-ref', + // 只传当前 manifest 的资产 ID:不传本地路径,也不传历史远端 ID。 + referenceAssetIds: ['asset-a', 'asset-b'], + }); + }); + test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => { const harness = createHarness((pollCount, api) => { if (pollCount === 1) { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx new file mode 100644 index 000000000..c70900b2c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + ResourceCanvasAssetGenerationPanelView, + type ResourceCanvasAssetGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC, + resourceCanvasAssetGenerationAcceptsReferences, + resourceCanvasAssetGenerationReferenceAssets, + resourceCanvasAssetGenerationReferenceError, + resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationUserReferenceLimit, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { + type ChatReference, + resourceReferenceFromAsset, +} from '../src/features/project-workspace/resourceReferences'; + +afterEach(() => { + cleanup(); +}); + +function action( + overrides: Partial & + Pick, +): ResourceCanvasAssetToolAction { + return { + route: 'asset', + audioKind: null, + assetName: `素材 ${overrides.label}`, + promptPlaceholder: '描述要生成什么', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, + ...overrides, + }; +} + +const imageAction = action({ + id: 'generate-image', + label: '生成图片', + assetKind: 'image', +}); +const iconSpecAction = action({ + id: 'generate-spec-icon', + label: '图标规范', + assetKind: 'icon-spec', + adjustableDimensions: false, + writesIconSpecReference: true, +}); +const uiPrototypeAction = action({ + id: 'generate-ui-prototype', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + requiresIconSpecReference: true, +}); +const spritesheetAction = action({ + id: 'generate-icon-spritesheet', + label: '生成图标素材', + assetKind: 'art-spritesheet', + requiresIconSpecReference: true, +}); + +function asset( + id: string, + mediaType: string, + localPath: string, + kind = 'image', +): GameCreationAppAssetManifestEntry { + return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; +} + +function resourceReference( + resourceId: string, + label: string, + mediaType = 'image/png', +): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'image', + mediaType, + label, + category: 'scene', + tags: [], + source: 'asset-picker', + }; +} + +describe('参考图模型', () => { + test('只有图集不接受用户参考,图标规范与普通生成一致', () => { + expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe( + true, + ); + expect(resourceCanvasAssetGenerationAcceptsReferences(iconSpecAction)).toBe( + true, + ); + expect(resourceCanvasAssetGenerationAcceptsReferences(uiPrototypeAction)).toBe( + true, + ); + expect( + resourceCanvasAssetGenerationAcceptsReferences(spritesheetAction), + ).toBe(false); + }); + + test('用户参考上限:普通生成五张,自带规范前置的入口四张,图集零张', () => { + expect(resourceCanvasAssetGenerationUserReferenceLimit(imageAction)).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + ); + expect(resourceCanvasAssetGenerationUserReferenceLimit(iconSpecAction)).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + ); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(uiPrototypeAction), + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(spritesheetAction), + ).toBe(0); + // 规范前置入口的四张用户参考 + 一张权威规范图,正好是总上限。 + expect( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES); + }); + + test('候选集只留当前项目已登记的图片', () => { + const candidates = resourceCanvasAssetGenerationReferenceAssets([ + asset('asset-image', 'image/png', 'assets/a.png'), + asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), + asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), + asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), + asset('asset-remote-only', 'image/png', ' '), + ]); + expect(candidates.map((item) => item.id)).toEqual(['asset-image']); + }); + + test('参考 ID 只取资源引用、按选择顺序去重', () => { + expect( + resourceCanvasAssetGenerationReferenceIds([ + resourceReference('asset-a', '素材 A'), + { + type: 'runtime-region', + label: '运行区域', + resourceIds: ['asset-x'], + source: 'runtime-picker', + }, + resourceReference('asset-b', '素材 B'), + resourceReference('asset-a', '素材 A 重名'), + resourceReference(' ', '空身份'), + ]), + ).toEqual(['asset-a', 'asset-b']); + }); + + test('超限给出可执行原因而不是静默截断', () => { + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + }), + ).toBeNull(); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES + 1, + }), + ).toContain('最多 5 张'); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: uiPrototypeAction, + referenceCount: + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + }), + ).toContain('最多 4 张'); + // 图集没有参考选择器:不存在「超限」这条用户可见反馈。 + expect( + resourceCanvasAssetGenerationReferenceError({ + action: spritesheetAction, + referenceCount: 3, + }), + ).toBeNull(); + }); +}); + +describe('生成面板的参考接线', () => { + test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); + const references = [ + resourceReferenceFromAsset(imageAsset, 'asset-picker'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + expect(panel.textContent).toContain('参考图 1/5'); + // 引用输入区与聊天 / 快速编辑同一份组件:可访问名就是「生成提示词」。 + expect(screen.getByLabelText('生成提示词')).not.toBeNull(); + + await user.click( + within(panel).getByRole('button', { name: '生成图片' }), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'image', + prompt: '画一只猫 @素材-a', + references: [expect.objectContaining({ resourceId: 'asset-a' })], + }); + }); + + test('图集入口不呈现参考选择,只提交纯提示词', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(panel.textContent).not.toContain('参考图'); + const prompt = screen.getByLabelText('生成提示词'); + expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA'); + + await user.click( + within(panel).getByRole('button', { name: '生成图标素材' }), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'art-spritesheet', + references: [], + }); + }); + + test('超限时提交被挡住并给出原因', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + + resourceReference(`asset-${id}`, `素材 ${id}`), + ), + }} + onSubmit={onSubmit} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect(panel.textContent).toContain('参考图 5/4'); + const submit = within(panel).getByRole('button', { + name: '生成 UI 设计图', + }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + await user.click(submit); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); From 2628b83d4bc4b74031d902fb2778b7f3e0089852 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 17:06:37 +0800 Subject: [PATCH 08/68] =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=8D=A1=E7=BB=9F=E4=B8=80=E6=98=BE=E7=A4=BA=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=90=8D=E5=B9=B6=E7=AE=80=E5=8C=96=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8D=A1=E5=8D=A1=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源卡新增卡面名称条,所有类型卡统一显示正式资源 label,长名单行省略、完整名进 title - 文档卡卡面改为居中图标,不再把正文前几行铺在卡面上,正文只在独立详情浮层读取 - 删除卡面正文摘要的取文本 helper 与行数/长度常量,移除对应 CSS 规则不留墓碑 - 名称条按媒体卡让出右下播放钮位置,替换血缘角标上移避免遮住名称 - 新增资源卡名称与文档卡卡面用例,并同步重命名链路与卡面正文的既有断言 --- apps/ai-game-creator-shell/src/styles.css | 73 ++-- .../src/view/project-development/index.tsx | 52 ++- .../resourceCardPreviewModel.ts | 68 +--- .../appSurface/project-development.suite.ts | 28 +- .../tests/resourceCanvasCardName.test.tsx | 360 ++++++++++++++++++ .../tests/resourceRename.test.tsx | 13 +- 6 files changed, 480 insertions(+), 114 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 5b0352492..b525ce3ac 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7494,6 +7494,48 @@ iframe.preview-frame { visibility: visible; } +/* + * 卡面名称:所有类型卡统一在底部显示正式资源名(`resource.label`)。 + * + * 名称来自现有正式命名链路——生成时 `assetName` 参与落盘名、用户重命名改写同一份 + * `localPath`,所以这里只消费投影 label,不另建第二份显示名。名称长了由省略号截断, + * 完整名称挂在 `title` 上;`pointer-events: none` 保证点击仍然落到整卡的选中按钮。 + * + * 圆角用 `inherit` 拿卡片自己的圆角(总览态与栏目态卡片圆角不同),再把上沿两角归零: + * 卡片本体是 `overflow: visible`,名称条不跟着圆角就会在卡片圆角外露出直角。 + */ +.game-resource-card-name { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + padding: 10px 8px 4px; + overflow: hidden; + border-radius: inherit; + border-top-left-radius: 0; + border-top-right-radius: 0; + background: linear-gradient( + to top, + rgb(255 253 250 / 96%) 52%, + rgb(255 253 250 / 0%) + ); + color: #4e382f; + font-size: 10px; + font-weight: 700; + line-height: 1.3; + letter-spacing: 0.01em; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 视频 / 音频卡右下角是 34px 播放钮(+9px 内边距),名称的右侧不钻到按钮下面。 */ +.game-resource-card[data-preview-kind='video'] .game-resource-card-name, +.game-resource-card[data-preview-kind='audio'] .game-resource-card-name { + padding-right: 50px; +} + .game-resource-card-type-badge { position: absolute; top: 8px; @@ -7522,11 +7564,12 @@ iframe.preview-frame { } /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 - 左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色 - 把这条关系与光环联系起来。 */ + 卡片底部整条是卡面名称(`.game-resource-card-name`),所以血缘角标压在名称条之上; + 右下角仍是媒体播放钮,最大宽度按右侧让出播放钮的宽度。 + 用「当前版本」同一支橙色把这条关系与光环联系起来。 */ .game-resource-card-lineage-badge { position: absolute; - bottom: 8px; + bottom: 32px; left: 8px; z-index: 2; display: inline-flex; @@ -7638,27 +7681,13 @@ iframe.preview-frame { display: none; } -.game-resource-card-document-summary { - display: -webkit-box; - max-height: 100%; - padding: 16px; - overflow: hidden; - color: #674c41; - font-size: 11px; - line-height: 1.55; - text-align: left; - /* 文档卡预览改为"前几行"(最多 3 行),必须保留换行:折成一行就看不出结构了。 */ - white-space: pre-line; - word-break: break-word; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; -} - /* - * 代码文件卡:不显示文件内容,只给代码图标 + 扩展名标签。 - * 与 `.game-resource-card-version-visual` 共用同一套居中排布口径,视觉上仍是同一族卡片。 + * 代码文件卡与文档卡:卡面都不显示正文 —— 代码卡给代码图标 + 扩展名标签, + * 文档卡只给居中的文档图标(正文只在独立的文档详情浮层里按原文 / 代码 / UI JSON 渲染)。 + * 两者与 `.game-resource-card-version-visual` 共用同一套居中排布口径,视觉上仍是同一族卡片。 */ -.game-resource-card-code-visual { +.game-resource-card-code-visual, +.game-resource-card-document-visual { display: grid; gap: 8px; width: 100%; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index d713a603a..12ac2247a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -298,7 +298,6 @@ import { type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, projectResourceCodeTypeLabel, - projectResourceDocumentPreviewText, projectResourceJsonPresentation, } from './resourceCardPreviewModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; @@ -793,17 +792,16 @@ const ResourceCard = memo(function ResourceCard({ : resourceCategoryLabel; /** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */ const codeTypeLabel = projectResourceCodeTypeLabel(resource.path); - const documentPreview = - !jsonPresentation && - preview.status === 'loaded' && - preview.preview.content !== undefined && - previewVariant !== null && - previewVariant !== 'code' - ? projectResourceDocumentPreviewText( - preview.preview.content, - previewVariant === 'markdown', - ) - : ''; + /** + * 文档卡(markdown / 纯文本,且不是 JSON 或 UI 设计):**卡面不显示正文**, + * 只给居中的文档图标。 + * + * 判据与代码卡同源,只看路径分流(`previewVariant`),**不依赖是否读到内容**: + * 等读完再决定画什么,只会把"半截正文"和多余的重排一起留在链路上。 + * 正文仍在独立详情浮层里按原文 / 代码 / UI JSON 口径读取,读取预算与权限不变。 + */ + const isDocumentCard = + !jsonPresentation && previewVariant !== null && previewVariant !== 'code'; useEffect(() => { const element = cardRef.current; @@ -983,13 +981,15 @@ const ResourceCard = memo(function ResourceCard({ ); } - if (previewVariant !== null && documentPreview) { + /** + * 文档卡:居中图标,**不显示正文摘要**(旧实现把前 3 行正文铺在卡面上, + * 与"卡面不显示无效正文片段"冲突)。原文预览、代码块与 UI JSON 识别都留在 + * 独立详情浮层,卡面这条链路不再读内容做展示。 + */ + if (isDocumentCard) { return ( - - {documentPreview} + + ); } @@ -1073,6 +1073,22 @@ const ResourceCard = memo(function ResourceCard({ + {/* + 卡面名称:**所有类型卡统一显示正式资源名**(`resource.label`)。 + + 它来自现有正式命名链路——manifest 资产的 `localPath` 就是生成时的 + `assetName` 落盘名,用户重命名也会改写同一份 `localPath`,因此这里消费 + 投影 label 即可,不从临时输入或历史任务名另取一份显示名,也不持久化第二份 + 名称。名称长了由 CSS 单行省略,完整名称在 `title` 上;`data-resource-name` + 是给端到端验收的稳定判据。 + */} + + {resource.label} + ` 与列表符号(保留缩进层级); - * - 去掉强调 / 行内代码 / 图片 / 链接的标记符号,保留可见文字; - * - **不折叠换行** —— 卡面要的是"前几行",压成一行就看不出结构了。 - */ -function stripMarkdownMarkers(content: string): string { - return content - .replace(/^\s*(?:```|~~~).*$/gmu, '') - .replace(/^\s{0,3}#{1,6}\s*/gmu, '') - .replace(/^\s{0,3}>\s?/gmu, '') - .replace(/^\s*[-+*]\s+/gmu, '') - .replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1') - .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1') - .replace(/`{1,3}([^`]*)`{1,3}/gu, '$1') - .replace(/\*\*([^*]+)\*\*/gu, '$1') - .replace(/__([^_]+)__/gu, '$1') - .replace(/(^|[^*])\*([^*\n]+)\*/gu, '$1$2') - .replace(/(^|[^_])_([^_\n]+)_/gu, '$1$2'); -} - -/** - * 文档卡面的前几行预览文本。 - * - * `isMarkdown` 为 `true` 时先做轻量标记清理;纯文本原样输出。 - * 逐行去掉多余空白(缩进保留最多 2 个空格)并按 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH` - * 单行截断,最多取 `PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT` 行。 - */ -export function projectResourceDocumentPreviewText( - content: string, - isMarkdown: boolean, -): string { - const normalized = isMarkdown ? stripMarkdownMarkers(content) : content; - const lines: string[] = []; - for (const rawLine of normalized.split(/\r?\n/u)) { - const line = rawLine.replace(/\t/gu, ' ').replace(/\s+$/u, ''); - const trimmed = line.trimStart(); - if (!trimmed) { - continue; - } - const indent = line.slice(0, line.length - trimmed.length).slice(0, 2); - const text = `${indent}${trimmed}`; - lines.push( - text.length > PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH - ? `${text.slice(0, PROJECT_RESOURCE_CARD_PREVIEW_LINE_LENGTH)}…` - : text, - ); - if (lines.length >= PROJECT_RESOURCE_CARD_PREVIEW_LINE_LIMIT) { - break; - } - } - return lines.join('\n'); -} diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 21c3f481c..02c8f889a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2979,7 +2979,13 @@ export function registerProjectWorkbenchFoundationTests() { await showResourcePage('角色与对象'); let heroDetailButton = await findResourceSelectButton('hero.png'); const heroCard = heroDetailButton.closest('.game-resource-card'); - expect(heroCard?.textContent).not.toContain('hero.png'); + // 卡面名称就是正式资源名(manifest `localPath` 的文件名,生成时来自 assetName、 + // 重命名时同步改写),长名由 CSS 省略、完整名在 `title` 上。 + const heroName = heroCard?.querySelector('.game-resource-card-name'); + expect(heroName?.textContent).toBe('hero.png'); + expect(heroName?.getAttribute('title')).toBe('hero.png'); + expect(heroName?.getAttribute('data-resource-name')).toBe('hero.png'); + // 卡面仍然只给"名称 + 类型",不铺完整路径与来源这类详细文本。 expect(heroCard?.textContent).not.toContain('assets/hero.png'); expect(heroCard?.textContent).not.toContain('Agent 生成'); // 角标显示的是**资源类型**(功能分类),等于它所在的画布栏目;不再是"图片"这类媒体类型。 @@ -3003,13 +3009,21 @@ export function registerProjectWorkbenchFoundationTests() { }); await showResourcePage('待归类'); act(() => observer.triggerVisible()); - const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/); - // 卡面正文按扩展名走文档分支(媒体类型),但角标是资源类型:`design-document` 不在 - // canonical 目录里 → 落「待归类」,与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。 + // 文档卡卡面**不铺正文**:只显示居中图标 + 正式资源名,正文只在独立的文档详情浮层里读。 + const documentCard = ( + await findResourceSelectButton('design.md') + ).closest('.game-resource-card'); expect( - documentSummary - .closest('.game-resource-card') - ?.querySelector('[data-resource-type="待归类"]'), + documentCard?.querySelector('.game-resource-card-name')?.textContent, + ).toBe('design.md'); + expect( + documentCard?.querySelector('.game-resource-card-document-visual'), + ).not.toBeNull(); + expect(documentCard?.textContent).not.toContain('这是安全的卡片正文摘要。'); + // 角标仍是资源类型:`design-document` 不在 canonical 目录里 → 落「待归类」, + // 与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。 + expect( + documentCard?.querySelector('[data-resource-type="待归类"]'), ).not.toBeNull(); await showResourcePage('角色与对象'); expect( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx new file mode 100644 index 000000000..60eb78499 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx @@ -0,0 +1,360 @@ +/** @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + findResourceSelectButton, + fireEvent, + installResizeObserverStub, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +vi.mock('@tauri-apps/api/core', async () => ({ + ...(await vi.importActual( + '@tauri-apps/api/core', + )), + invoke: (command: string, args?: Record) => + window.__TAURI__!.core.invoke(command, args), +})); + +const PROJECT_PATH = '/tmp/workbench-card-name'; +/** + * 生成落盘名的真实形状:`assets/canvas-generated/{毫秒}-{assetName}.{ext}`。 + * 卡面名称必须消费这条正式链路的结果,而不是另造一份显示名。 + */ +const GENERATED_IMAGE_NAME = + '1758000000000-夜行侠主角三视图与战斗姿态设定稿.png'; +const GENERATED_IMAGE_PATH = `assets/canvas-generated/${GENERATED_IMAGE_NAME}`; +const DOCUMENT_BODY = '# 玩法摘要\n\n这段正文只应出现在独立详情浮层里。'; + +function createCardNameManifest(): GameCreationAppManifest { + const manifest = createGameCreationAppManifest( + 'workbench-card-name', + '卡片名称测试', + ); + manifest.assets = [ + { + id: 'asset-hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'generated' }, + }, + { + id: 'asset-scene', + kind: 'scene', + mediaType: 'image/png', + localPath: GENERATED_IMAGE_PATH, + source: { kind: 'generated' }, + }, + { + id: 'asset-notes', + kind: 'design-document', + mediaType: 'text/markdown', + localPath: 'memory/设计文档.md', + source: { kind: 'generated' }, + }, + { + id: 'asset-code', + kind: 'code', + mediaType: 'text/html', + localPath: 'game/index.html', + source: { kind: 'generated' }, + }, + { + id: 'asset-bgm', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/theme.mp3', + source: { kind: 'generated' }, + }, + ]; + manifest.versions = [ + { + versionId: 'version-initial', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + return manifest; +} + +function installInvoke( + implementation: ( + command: string, + args?: Record, + ) => Promise, +) { + const invoke = vi.fn(implementation); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof invoke } }; + } + ).__TAURI__ = { core: { invoke } }; + return invoke; +} + +function installManifestInvoke(projectId: string) { + return installInvoke(async (command, args) => { + if (command === 'read_local_project_resource_graph') { + const ids = (args?.resources as Array<{ resourceId: string }>).map( + (item) => item.resourceId, + ); + return { + resourceIds: ids, + referenceEdges: [], + taskFlows: [], + producerAssignments: [], + dependencyDepths: ids.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + connectionIndex: ids.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if ( + command === 'read_local_project_resource_canvas_layout' || + command === 'update_local_project_resource_canvas_layout' + ) { + const layout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions ?? [], + updatedAt: 1, + }; + return command.startsWith('update_') + ? { status: 'updated', layout } + : layout; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 12, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: DOCUMENT_BODY.length, + content: DOCUMENT_BODY, + }; + } + if (command === 'read_local_project_media_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'audio/mpeg', + byteLen: 32, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + if ( + command === 'list_pending_local_project_resource_edits' || + command === 'list_local_project_asset_generations' + ) { + return []; + } + throw new Error(`unexpected command: ${command}`); + }); +} + +/** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */ +async function openResourceBookCategory(label: string) { + if (document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="main"]'), + ).not.toBeNull(), + ); + } + fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(), + ); +} + +/** 卡面名称节点断言:文本、`title` 与稳定 DOM 判据都是同一个正式资源名。 */ +async function expectCardName(name: string) { + const card = (await findResourceSelectButton(name)).closest( + '.game-resource-card', + )!; + const nameNode = card.querySelector('.game-resource-card-name'); + expect(nameNode?.textContent).toBe(name); + expect(nameNode?.getAttribute('title')).toBe(name); + expect(nameNode?.getAttribute('data-resource-name')).toBe(name); + return card; +} + +function renderWorkbench(manifest: GameCreationAppManifest) { + return render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); +} + +function stylesSource() { + return readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); +} + +/** 取 CSS 源文件里某条规则的声明体。 */ +function ruleBody(styles: string, selector: string) { + const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); + expect(match, `${selector} 规则缺失`).not.toBeNull(); + return match![1]!; +} + +afterEach(() => { + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; +}); + +describe('资源卡名称与文档卡卡面', () => { + /** + * 名称口径:所有类型卡都显示**正式资源 label**——manifest `localPath` 的文件名。 + * 生成时它是 `assetName` 的落盘名,用户重命名会改写同一份 `localPath`, + * 卡片不另取临时输入或历史任务名,也不持久化第二份显示名。 + */ + it('所有类型卡都显示正式资源名,且不把目录路径铺到卡面上', async () => { + installResizeObserverStub(); + installManifestInvoke('workbench-card-name'); + renderWorkbench(createCardNameManifest()); + + await openResourceBookCategory('角色与对象'); + const heroCard = await expectCardName('hero.png'); + expect(heroCard.textContent).not.toContain('assets/'); + expect(heroCard.textContent).not.toContain('Agent 生成'); + + await openResourceBookCategory('场景与环境'); + // 长名不被截断在数据层:这里拿到的是完整名,单行省略只由 CSS 负责, + // 用户悬停 `title` 仍能读到完整名称。 + const sceneCard = await expectCardName(GENERATED_IMAGE_NAME); + expect(sceneCard.textContent).not.toContain('assets/'); + + await openResourceBookCategory('音频'); + await expectCardName('theme.mp3'); + + await openResourceBookCategory('待归类'); + await expectCardName('设计文档.md'); + await expectCardName('index.html'); + + await openResourceBookCategory('项目版本'); + await expectCardName('版本 1'); + }); + + it('文档卡以居中图标呈现,正文只在独立详情里读', async () => { + installResizeObserverStub(); + const invoke = installManifestInvoke('workbench-card-name'); + renderWorkbench(createCardNameManifest()); + + await openResourceBookCategory('待归类'); + const documentCard = await expectCardName('设计文档.md'); + // 先证明正文**确实读到过**:读取链路没有被这次改动掐掉,卡面仍旧不铺摘要。 + await waitFor(() => + expect(documentCard.getAttribute('data-preview-status')).toBe('loaded'), + ); + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'read_local_project_text_preview' && + args?.relativePath === 'memory/设计文档.md', + ), + ).toBe(true); + expect( + documentCard.querySelector('.game-resource-card-document-visual'), + ).not.toBeNull(); + expect(documentCard.textContent).not.toContain(DOCUMENT_BODY); + expect(documentCard.textContent).not.toContain( + '这段正文只应出现在独立详情浮层里', + ); + expect( + documentCard.querySelector('.game-resource-card-document-summary'), + ).toBeNull(); + // 代码卡同样只在卡面给图标 + 类型标签,名称照常显示。 + const codeCard = await expectCardName('index.html'); + expect( + codeCard.querySelector('.game-resource-card-code-visual'), + ).not.toBeNull(); + + // 独立详情不回归:选中文档卡 → 工具条「预览」→ 正文按原文读到浮层里。 + fireEvent.click( + within(documentCard).getByRole('button', { + name: '选中资源:待归类 设计文档.md', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: '预览' })); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + await waitFor(() => + expect(dialog.textContent).toContain('这段正文只应出现在独立详情浮层里'), + ); + }); + + /** + * jsdom 不加载样式表:长名单行省略、名称条不挡点击这些声明本身在源码层钉住; + * 真机像素表现仍需人工核验。 + */ + it('卡面名称的省略与层级声明固定在样式表里', () => { + const styles = stylesSource(); + const nameRule = ruleBody(styles, '\\.game-resource-card-name'); + expect(nameRule).toMatch(/overflow:\s*hidden/u); + expect(nameRule).toMatch(/text-overflow:\s*ellipsis/u); + expect(nameRule).toMatch(/white-space:\s*nowrap/u); + expect(nameRule).toMatch(/pointer-events:\s*none/u); + // 名称条铺在整卡选中按钮之上、右下角播放钮之下,两边都不能被盖住。 + expect(nameRule).toMatch(/z-index:\s*2/u); + expect( + ruleBody( + styles, + "\\.game-resource-card\\[data-preview-kind='audio'\\] \\.game-resource-card-name", + ), + ).toMatch(/padding-right:\s*50px/u); + // 旧的卡面正文摘要样式必须随正文一起消失,不留墓碑规则。 + expect(styles).not.toContain('.game-resource-card-document-summary'); + // 文档卡与代码卡共用同一套居中排布。 + const documentVisualRule = ruleBody( + styles, + '\\.game-resource-card-code-visual,\\s*\\.game-resource-card-document-visual', + ); + expect(documentVisualRule).toMatch(/place-items:\s*center/u); + expect(documentVisualRule).toMatch(/align-content:\s*center/u); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx index 3846ae0bf..df0ba4b55 100644 --- a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx @@ -231,11 +231,16 @@ describe('素材重命名前端链路', () => { rendered.rerenderWith(renamedManifest); await waitFor(() => { - expect( - screen.getByRole('button', { + const renamedCard = screen + .getByRole('button', { name: '选中资源:角色与对象 hero-v2.png', - }), - ).not.toBeNull(); + }) + .closest('.game-resource-card'); + // 卡面名称与重命名后的 manifest `localPath` 同步:名称就是正式文件名的投影, + // 不存在第二份需要一起改的显示名。 + const nameNode = renamedCard?.querySelector('.game-resource-card-name'); + expect(nameNode?.textContent).toBe('hero-v2.png'); + expect(nameNode?.getAttribute('title')).toBe('hero-v2.png'); }); }); From 3ba6168c6aacdc4116ad5195e75a831505bd5f63 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 17:15:38 +0800 Subject: [PATCH 09/68] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=8D=A1=E5=90=8D=E7=A7=B0=E4=B8=8E=E7=94=BB=E5=B8=83=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=BA=A4=E4=BA=92=E4=BA=A7=E5=93=81=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一卡面名称和文档图标展示要求 明确当前栏目整理与多选移动的撤销边界 --- docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index feb480ae6..36f6c9fa7 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -60,7 +60,9 @@ #### 3.3.2 本体化资源卡交互与性能合同 -- 卡片默认可视区域不再显示文件名、资源名称、来源、路径、任务和媒体类型等详细文本。这些字段继续进入搜索索引和中央详情;卡片“打开详情”入口的可访问名称必须包含稳定可辨识的资源名与类别。 +- 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。 +- 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。 +- 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。 - 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 ` - - {appUpdateStatus} - -
+ {appUpdateCheckEnabled ? ( +
+ + + {appUpdateStatus} + +
+ ) : null} ) : null}
diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index f9021f3d0..188909bc8 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -1,124 +1,58 @@ -import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; -import { openUrl } from '@tauri-apps/plugin-opener'; +import { + check, + type DownloadEvent, + type Update, +} from '@tauri-apps/plugin-updater'; -import { APP_VERSION } from '../app/appMetadata'; +import { appUpdateCheckEnabled } from '../app/featureFlags'; import { resolveTauriInvoke } from '../app/tauri'; -/** OSS 上的 AGC 更新清单;发布时可覆盖为同一受信任 OSS 域名下的地址。 */ -export const AGC_UPDATE_MANIFEST_URL = - import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || - 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json'; -export const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT = - 'agc-update-download-progress'; - -export type AppUpdateManifest = { +/** 更新提示所需的元数据;清单请求、版本比较、下载、校验与安装都由官方更新插件在原生侧完成。 */ +export type AppUpdateInfo = { version: string; - downloadUrl: string; - sha256?: string; - size?: number; + currentVersion: string; releaseNotes?: string; }; -export type AppUpdateInfo = AppUpdateManifest & { - currentVersion: string; +export type AppUpdateProgress = { + downloadedBytes: number; + totalBytes?: number; }; +let pendingUpdate: Update | null = null; let updateCheckPromise: Promise | null = null; const updateListeners = new Set<(update: AppUpdateInfo | null) => void>(); -function parseVersion(value: string) { - const match = value - .trim() - .replace(/^v/iu, '') - .match(/^(\d+)\.(\d+)(?:\.(\d+))?/u); - return match - ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] - : null; -} - -export function isNewerVersion(candidate: string, current: string) { - const next = parseVersion(candidate); - const installed = parseVersion(current); - if (!next || !installed) return false; - for (let index = 0; index < next.length; index += 1) { - const nextValue = next[index] ?? 0; - const installedValue = installed[index] ?? 0; - if (nextValue !== installedValue) return nextValue > installedValue; - } - return false; -} - -export function parseAppUpdateManifest( - value: unknown, -): AppUpdateManifest | null { - if (!value || typeof value !== 'object') return null; - const record = value as Record; - const version = - typeof record.version === 'string' ? record.version.trim() : ''; - const downloadUrl = - typeof record.downloadUrl === 'string' ? record.downloadUrl.trim() : ''; - if (!version || !downloadUrl) return null; - try { - const url = new URL(downloadUrl); - if (url.protocol !== 'https:') return null; - } catch { - return null; - } - const sha256 = - typeof record.sha256 === 'string' - ? record.sha256.trim().toLowerCase() - : undefined; - if (sha256 && !/^[a-f0-9]{64}$/u.test(sha256)) return null; - const size = - typeof record.size === 'number' && - Number.isSafeInteger(record.size) && - record.size > 0 - ? record.size - : undefined; - const releaseNotes = - typeof record.releaseNotes === 'string' - ? record.releaseNotes.trim() - : undefined; +function toAppUpdateInfo(update: Update): AppUpdateInfo { return { - version, - downloadUrl, - ...(sha256 ? { sha256 } : {}), - ...(size ? { size } : {}), - ...(releaseNotes ? { releaseNotes } : {}), + version: update.version, + currentVersion: update.currentVersion, + ...(update.body ? { releaseNotes: update.body } : {}), }; } -async function fetchUpdateManifest() { - const response = - typeof window !== 'undefined' && window.__TAURI__ - ? await tauriHttpFetch(AGC_UPDATE_MANIFEST_URL, { - method: 'GET', - headers: { Accept: 'application/json' }, - }) - : await fetch(AGC_UPDATE_MANIFEST_URL, { - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error(`更新清单请求失败:${response.status}`); - return parseAppUpdateManifest(await response.json()); +async function runAppUpdateCheck(): Promise { + try { + const update = await check(); + pendingUpdate = update; + const info = update ? toAppUpdateInfo(update) : null; + updateListeners.forEach((listener) => listener(info)); + return info; + } catch { + // 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。 + pendingUpdate = null; + return null; + } } -/** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */ +/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */ export function checkForAppUpdate( options: { force?: boolean } = {}, ): Promise { + // 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。 + if (!appUpdateCheckEnabled) return Promise.resolve(null); if (options.force) updateCheckPromise = null; - if (!updateCheckPromise) { - updateCheckPromise = fetchUpdateManifest() - .then((manifest) => { - const update = - manifest && isNewerVersion(manifest.version, APP_VERSION) - ? { ...manifest, currentVersion: APP_VERSION } - : null; - updateListeners.forEach((listener) => listener(update)); - return update; - }) - .catch(() => null); - } + updateCheckPromise ??= runAppUpdateCheck(); return updateCheckPromise; } @@ -129,28 +63,45 @@ export function subscribeToAppUpdate( return () => updateListeners.delete(listener); } -export async function downloadAppUpdate( - downloadUrl: string, - integrity: Pick = {}, +/** + * 下载并安装最近一次检测到的更新。 + * + * Windows 上安装程序接管后客户端退出并由安装程序重启;macOS / Linux 在安装完成后由本函数重启进程。 + */ +export async function installAppUpdate( + onProgress: (progress: AppUpdateProgress) => void = () => undefined, ) { - const url = new URL(downloadUrl); - if (url.protocol !== 'https:') throw new Error('更新下载地址必须使用 HTTPS'); - if (typeof window !== 'undefined' && window.__TAURI__) { - const invoke = resolveTauriInvoke(); - if (invoke) { - return await invoke('download_agc_update', { - downloadUrl: url.toString(), - expectedSha256: integrity.sha256, - expectedSize: integrity.size, - }); - } else { - await openUrl(url.toString()); + const update = pendingUpdate; + if (!update) throw new Error('没有可安装的更新'); + let downloadedBytes = 0; + let totalBytes: number | undefined; + const report = () => + onProgress({ + downloadedBytes, + ...(totalBytes ? { totalBytes } : {}), + }); + await update.downloadAndInstall((event: DownloadEvent) => { + if (event.event === 'Started') { + downloadedBytes = 0; + totalBytes = event.data.contentLength; + } else if (event.event === 'Progress') { + downloadedBytes += event.data.chunkLength; } - return; - } - window.open(url.toString(), '_blank', 'noopener,noreferrer'); + report(); + }); + // 失败时保留待装更新,让「重试」仍能走同一条安装链路。 + pendingUpdate = null; + restartAppAfterUpdate(); +} + +function restartAppAfterUpdate() { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + // Windows 的 install 已在启动安装程序后退出进程,这里只覆盖 macOS / Linux 的重启收敛。 + void invoke('restart_agc_app').catch(() => undefined); } export function resetAppUpdateCheckForTests() { + pendingUpdate = null; updateCheckPromise = null; } diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index 8bbe1e596..bd89c3a3a 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -1,47 +1,137 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { check } from '@tauri-apps/plugin-updater'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - isNewerVersion, - parseAppUpdateManifest, - resetAppUpdateCheckForTests, -} from '../src/services/appUpdate'; +vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() })); -afterEach(() => resetAppUpdateCheckForTests()); +const checkMock = vi.mocked(check); -describe('AGC update manifest', () => { - it('compares semantic versions and accepts v prefixes', () => { - expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true); - expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false); - expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false); +type FakeDownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' }; + +function fakeUpdate() { + return { + version: '99.0.0', + currentVersion: '0.1.47', + body: '修复与改进', + downloadAndInstall: vi.fn( + async (onEvent: (event: FakeDownloadEvent) => void) => { + onEvent({ event: 'Started', data: { contentLength: 100 } }); + onEvent({ event: 'Progress', data: { chunkLength: 40 } }); + onEvent({ event: 'Progress', data: { chunkLength: 60 } }); + onEvent({ event: 'Finished' }); + }, + ), + }; +} + +function stubTauriWindow() { + const invoke = vi.fn(async () => undefined); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + return invoke; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + checkMock.mockReset(); +}); + +describe('AGC 客户端更新', () => { + it('开发态开关关闭时不请求清单', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0'); + vi.resetModules(); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + expect(checkMock).not.toHaveBeenCalled(); + resetAppUpdateCheckForTests(); }); - it('validates an OSS manifest and rejects non-HTTPS downloads', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - }), - ).toMatchObject({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', + it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockResolvedValue(fakeUpdate() as never); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toEqual({ + version: '99.0.0', + currentVersion: '0.1.47', + releaseNotes: '修复与改进', }); - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'http://oss.example/agc.exe', - }), - ).toBeNull(); + await checkForAppUpdate(); + expect(checkMock).toHaveBeenCalledTimes(1); + resetAppUpdateCheckForTests(); }); - it('preserves multiline release notes', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - releaseNotes: '第一行\n第二行\r\n第三行', - }), - ).toMatchObject({ - releaseNotes: '第一行\n第二行\r\n第三行', - }); + it('清单缺失或网络失败时静默按无更新收口', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockRejectedValue(new Error('updater: manifest 404')); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + resetAppUpdateCheckForTests(); + }); + + it('安装时按下载事件上报进度并在完成后重启进程', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const invoke = stubTauriWindow(); + const update = fakeUpdate(); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + const progress: Array<{ downloadedBytes: number; totalBytes?: number }> = + []; + await installAppUpdate((value) => progress.push(value)); + + expect(update.downloadAndInstall).toHaveBeenCalledTimes(1); + expect(progress).toEqual([ + { downloadedBytes: 0, totalBytes: 100 }, + { downloadedBytes: 40, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + ]); + expect(invoke).toHaveBeenCalledWith('restart_agc_app'); + resetAppUpdateCheckForTests(); + }); + + it('没有待安装更新时安装请求失败关闭', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const { installAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新'); + resetAppUpdateCheckForTests(); + }); + + it('下载失败后仍可重试安装', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const update = fakeUpdate(); + update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败')); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + // 重试仍能拿到待装更新,而不是报“没有可安装的更新”。 + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + expect(update.downloadAndInstall).toHaveBeenCalledTimes(2); + resetAppUpdateCheckForTests(); }); }); diff --git a/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts new file mode 100644 index 000000000..0f93e5095 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; + +import { + agcAppUpdateCheckEnvKey, + withAgcDevFeatureFlags, +} from '../scripts/dev-feature-flags.mjs'; + +describe('AGC dev 特性开关环境', () => { + test('未显式配置时下发关闭检测更新的默认值', () => { + expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({ + KEEP_ME: 'yes', + [agcAppUpdateCheckEnvKey]: '0', + }); + }); + + test('保留显式配置的开关取值,忽略空白取值', () => { + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' }); + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/featureFlags.test.ts b/apps/ai-game-creator-shell/tests/featureFlags.test.ts new file mode 100644 index 000000000..1ee1092b4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/featureFlags.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags'; + +describe('AGC 客户端特性开关', () => { + it('开发态(agc 启动)默认关闭检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', true)).toBe(false); + }); + + it('正式包默认开启检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', false)).toBe(true); + }); + + it('显式配置的开关优先于环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true); + expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false); + }); + + it('忽略无法识别的开关取值并回落到环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false); + expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md new file mode 100644 index 000000000..4e93b3844 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md @@ -0,0 +1,37 @@ +# 【实施计划】AGC 客户端更新切换到官方更新插件 + +| 字段 | 值 | +| --------- | ----------------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 客户端原生侧(依赖、插件注册、更新相关命令与其测试)、AGC 前端更新服务与更新提示、「关于」页检查入口、capability 与 Tauri 配置、AGC 客户端测试、主规范与开发运维文档。 +- 明确不修改:发布脚本与 Jenkins(渠道化属于下一个里程碑)、OSS 对象布局、SpacetimeDB、`/api/external/v1`、网站与其它 App。 + +## 实现顺序 + +1. 生成发布签名密钥对:私钥落在仓库外 `%USERPROFILE%\.tauri\`,公钥写入客户端配置(公钥发布后不可更换)。 +2. 原生侧:加入官方更新插件依赖并注册;删除自研更新下载命令、下载进度事件、安装器启动逻辑与其专属测试;新增供 macOS 安装后重启的应用命令。 +3. 配置与权限:打开更新产物生成,写入公钥、渠道端点(默认 Windows 渠道)与 Windows 静默安装模式;capability 增加更新权限,并移除只为自研清单放行的 OSS 白名单与 CSP 连接项。 +4. 前端:更新服务改为调用官方插件(检查、下载、进度、安装、重启收敛),删除自研清单解析、版本比较与下载实现;更新提示改用插件进度回调;保留开发态特性开关语义。 +5. 测试:改写更新服务定向用例(开关关闭不发请求、更新元数据映射、失败静默、进度与重启、无待装更新时失败关闭)。 +6. 文档:更新技术方案与开发运维说明,删除自研链路描述。 + +## 验证命令 + +1. `npm --prefix apps/ai-game-creator-shell run typecheck`(含 `check-config.mjs` 与 skill-pack 校验) +2. `npx vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts apps/ai-game-creator-shell/tests/featureFlags.test.ts apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts` +3. `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +4. `npx eslint` / `npx prettier --check`(改动文件) +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +6. 运行时:`npm run agc` 启动不产生更新清单请求;检索确认自研命令、事件与白名单条目无残留。 + +## 风险与回滚点 + +- 公钥不可更换:密钥已生成但尚未发布任何签名版本,若需要带密码的私钥仍可在首次发布前重新生成。 +- Windows 安装模式由插件配置决定(本里程碑固定 `quiet`,与旧 PowerShell `/S` 一致);若改为 `passive` 会多出安装进度条 UI。 +- 插件在 Windows 上安装成功后自行退出进程,前端不再有机会更新界面;提示面板的完成态只在 macOS / Linux 可见。 +- 回滚点:改动集中在客户端与配置,回滚后即可退回自研链路;旧 OSS `agc/latest.json` 在发布管线渠道化前不删除。 diff --git a/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md new file mode 100644 index 000000000..94fb23092 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md @@ -0,0 +1,37 @@ +# 【实施计划】AGC 更新发布管线渠道化 + +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 发布脚本(`apps/ai-game-creator-shell/scripts/build-release.mjs`、`release-upload.mjs` 及其测试)、AGC 发布流水线 `jenkins/Jenkinsfile.ai-game-creator-shell-build`、开发运维与技术方案文档。 +- 明确不修改:客户端插件接入与前端更新服务(上一里程碑已完成)、SpacetimeDB、`/api/external/v1`、网站与其它 App、其它 Jenkins Job。 +- 不执行 OSS 上传:本里程碑只交付脚本、流水线定义与本地可验证产物;真实发布需要单独授权与凭据。 + +## 实现顺序 + +1. 发布脚本:解析并校验渠道(渠道与目标平台绑定,未显式指定时按平台取默认渠道),把渠道写进远端清单地址与构建期端点配置。 +2. 清单生成:按渠道产出官方更新插件清单(版本、发布说明、发布时间、平台键与签名),universal macOS 产物同时挂两个平台键;缺少签名或签名为空时失败关闭。 +3. 迁移桥:Windows 渠道额外产出旧协议 sha256 清单,指向同一渠道的最新安装包,供已发布客户端升级到新协议。 +4. 上传:按渠道写版本目录(安装包与签名)与渠道 latest 指针,旧协议指针单独覆盖写。 +5. 流水线:新增渠道参数与签名凭据注入,归档安装包、签名、渠道清单与 commit。 +6. 测试与文档:更新发布脚本单测(渠道校验、清单结构、签名缺失失败关闭、旧协议清单),同步开发运维与技术方案。 + +## 验证命令 + +1. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` +2. 本地清单 smoke:伪造 bundle 目录 + 真实签名私钥,断言渠道清单与旧协议清单结构、缺少签名时失败关闭 +3. `npm --prefix apps/ai-game-creator-shell run typecheck` +4. `npm run ai-game-creator-shell:build -- --no-bundle`(渠道端点注入后的构建 smoke;不改版本、不读远端清单、不生成清单) +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`、prettier 与 eslint(改动文件) + +## 风险与回滚点 + +- 版本递增按渠道独立:`dev-win` 与 `dev-mac` 的清单地址不同,互不影响;旧协议指针只由 `dev-win` 写入。 +- 签名缺失即失败关闭:构建机未注入签名私钥时发布中止,不产生半成品清单。 +- 渠道端点写进产物:渠道名一旦发布不可改名(改名等于已发布客户端再也找不到更新)。 +- 回滚点:发布脚本与流水线都在本里程碑内,回滚后客户端仍可用原先的自研清单协议;迁移桥可独立停用。 diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md new file mode 100644 index 000000000..b9b84ef2e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md @@ -0,0 +1,46 @@ +# 【里程碑】AGC macOS 渠道更新落地 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | deferred | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +`dev-mac` 渠道可产出并发布 macOS 更新包,客户端在 macOS 上完成检查、安装与重启接管新版本。 + +## 范围 + +- macOS 更新产物:按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),更新包与其签名按渠道约定生成并上传,清单把同一对象挂到两个 macOS 平台键。 +- macOS 安装后的重启收敛:安装完成后由客户端重启进程运行新版本,不依赖安装程序代为重启。 +- macOS 代码签名与公证依赖的确认与记录:未签名或未公证的产物视为不可发布。 +- macOS 构建执行环境(本机 mac 或新增 macOS 节点)与渠道发布的衔接方式。 + +## 不在范围内 + +- Windows 渠道行为调整。 +- 微软商店或 App Store 分发。 +- 更新包体积优化与增量更新。 + +## 依赖与前置条件 + +- 客户端插件化与发布管线渠道化两个里程碑已验收。 +- macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。 +- macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。 + +本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。 + +## 验收标准 + +- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。 +- [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。 +- [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。 +- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。 + +## 证据要求 + +- 自动化:macOS 更新产物选择与清单生成用例、仓库门禁。 +- 运行时:macOS 上一次真实更新闭环(含重启后版本核对),OSS 对象与清单核对。 +- 边界:签名校验失败、公证缺失、渠道缺少 macOS 平台条目、跨架构不匹配时的表现。 diff --git a/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md new file mode 100644 index 000000000..48930772e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md @@ -0,0 +1,45 @@ +# 【里程碑】AGC 客户端更新切换到官方更新插件 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +客户端自动更新的检查、下载、签名校验与安装改由 Tauri 官方更新插件承担,前端只保留触发与展示,并按渠道读取清单;开发态继续不检查更新。 + +## 范围 + +- 官方更新插件在客户端两侧接入:原生侧注册与配置,前端调用官方 API 替代自研检查与下载。 +- 渠道作为构建期常量进入客户端:每个渠道的产物只读该渠道清单,运行期不切换渠道。 +- 保留并复核现有开发态特性开关语义:开发态不检查更新、不显示更新入口。 +- 更新能力只授予客户端主窗口。 + +## 不在范围内 + +- 发布管线与 OSS 对象布局的渠道化改造。 +- macOS 产物落地、签名与公证。 +- 旧客户端迁移桥(是否保留旧清单指针)。 + +## 依赖与前置条件 + +- 发布签名公钥可用;公钥写入客户端配置,来源见主规范未决问题。 +- 渠道清单地址与对象布局按主规范约定确定,渠道集合固定为 `dev-win` 与 `dev-mac`。 +- 官方插件版本与当前 Tauri 主版本兼容。 + +## 验收标准 + +- [ ] 正式包走官方更新插件的检查与安装路径;更新包校验失败时必须拒绝安装并清理临时文件。 +- [ ] 客户端只请求本渠道清单,且不因清单缺失、格式错误或网络失败阻塞启动。 +- [ ] 开发态启动不产生任何更新清单请求,也不显示更新入口。 +- [ ] 更新能力只授予客户端主窗口,其它窗口调用被拒绝。 +- [ ] 自研清单解析、下载命令、下载进度事件与相应的 CSP / HTTP 白名单放行整条删除,无残留兼容分支。 + +## 证据要求 + +- 自动化:前端定向用例(渠道映射、开发态开关、失败关闭)、原生侧定向用例、类型检查与仓库门禁。 +- 运行时:`agc` 开发启动无清单请求;使用测试渠道清单完成一次真实检查与安装闭环(含升级后重启)。 +- 边界:签名不匹配、下载中断、清单 404、渠道缺少当前平台条目、非主窗口调用。 diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md new file mode 100644 index 000000000..5191b0129 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md @@ -0,0 +1,47 @@ +# 【里程碑】AGC 更新发布管线渠道化 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +构建与发布管线按渠道产出官方更新插件要求的清单与签名产物并上传到渠道路径,发布入口可通过渠道参数在渠道之间切换。 + +## 范围 + +- 构建期按渠道生成清单:版本按渠道独立递增,清单包含该渠道平台的下载地址与签名;`dev-mac` 的 universal 包按同一地址与签名同时写入 `darwin-aarch64` 与 `darwin-x86_64`。 +- 构建期生成更新产物签名,并在缺少签名私钥或私钥不可用时失败关闭。 +- 渠道参数与目标平台绑定校验:Windows 目标只能发布 `dev-win`,macOS 目标只能发布 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 上传按渠道落位:安装包与签名进版本目录,清单覆盖写渠道路径的 latest 指针。 +- Jenkins 流水线增加渠道参数与签名凭据注入,凭据不落盘、不进日志、不进归档。 + +## 不在范围内 + +- 客户端侧的更新链路改造。 +- macOS 构建环境建设与 mac 产物签名、公证。 +- 旧客户端迁移桥;若决定保留,作为本里程碑的可选增量单独评审。 + +## 依赖与前置条件 + +- 客户端切换到官方更新插件的里程碑已验收:清单格式、公钥与客户端期望一致。 +- 签名密钥对已生成并进入构建凭据,公钥已写入客户端配置。 +- OSS 上传凭据与既有发布入口可复用。 + +## 验收标准 + +- [ ] 指定渠道发布时该渠道清单版本按渠道独立递增,另一个渠道清单不受影响。 +- [ ] 渠道与目标平台不匹配、缺少签名私钥或私钥密码错误时发布失败关闭,不产生半成品清单。 +- [ ] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配。 +- [ ] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。 +- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。 +- [ ] 未显式指定渠道时按目标平台取默认渠道,且 `--no-bundle` smoke 路径仍不读远端版本、不改版本、不生成清单。 + +## 证据要求 + +- 自动化:发布脚本单测(渠道解析与校验、版本递增、清单结构、签名缺失失败关闭)、仓库门禁。 +- 运行时:一次真实渠道发布加 OSS 对象核对(清单、安装包、签名),并用该清单触发一次客户端更新闭环。 +- 边界:渠道与平台不匹配、签名密钥缺失、远端清单 404、远端清单格式非法、重复发布时的 latest 覆盖。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 29eae7a7f..a20a900f1 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,68 +1,129 @@ # AGC 客户端更新检查与下载 -## 交付范围 +更新时间:`2026-09-17` -AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默认位于 -`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`,构建时可用 -`VITE_AGC_UPDATE_MANIFEST_URL` 覆盖为同一受信任 OSS 域名下的 HTTPS 地址。客户端版本取 -`apps/ai-game-creator-shell/package.json`,通过 `version` 与清单版本比较;只有远端版本更高时显示更新提示。 +本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 -清单格式: +## 目标 + +- 客户端自动更新改用 Tauri 官方 `tauri-plugin-updater`:清单请求、版本比较、更新包下载、签名校验、安装与退出全部在原生侧完成;前端只负责触发、展示和渠道选择。 +- 更新按渠道分发。当前渠道集合为 `dev-win`(Windows x64)与 `dev-mac`(macOS);构建管线按渠道产出并上传清单,客户端只读取自己渠道的清单。 +- 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。 + +## 非目标 + +- 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。 +- 不做后台静默自动安装:是否下载安装始终由用户在更新提示里确认(仅「是否显示提示」受渠道与开发态开关影响)。 +- 不支持应用商店分发(Microsoft Store / App Store)、移动端更新和企业内网自建更新服务。 +- 不为自研 sha256 清单协议保留长期实现;迁移桥(见「契约与迁移」)只用于把已发布客户端带到新协议,随后整条删除。 + +## 入口与边界 + +- 用户入口: + - 客户端启动时在根窗口检查一次渠道清单,发现新版本时显示更新提示,用户可下载并安装。 + - 运行时设置「关于」页提供手动检查更新(强制刷新)。 +- 涉及模块:AGC 客户端(Rust `src-tauri`、前端 `src/`)、AGC 构建与发布脚本(`apps/ai-game-creator-shell/scripts/`)、Jenkins 发布流水线、OSS 对象布局。 +- 正式状态来源: + - 客户端当前版本以 Tauri app version 为唯一权威来源(`tauri.conf.json`,由发布脚本与 `package.json`、`Cargo.toml`、`Cargo.lock` 同步递增)。 + - 远端最新版本取自当前渠道的 `latest.json`。 +- 信任边界:清单地址在构建期确定并烘焙进产物;客户端不接受用户输入、后端响应或项目文件提供的更新地址,也不回退到其它渠道或旧协议地址。 + +## 必须成立的行为 + +### 正常路径 + +- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。 +- 用户确认后下载更新包:下载期间显示进度与已下载字节数;下载完成后按平台安装。 +- Windows 使用静默安装模式(NSIS `quiet`),安装启动成功后客户端退出并由安装程序重启新版本;macOS 由客户端在安装完成后重启进程接管新版本。 +- 渠道在构建期确定并烘焙进产物:`dev-win` 产物只读 `dev-win` 清单,`dev-mac` 产物只读 `dev-mac` 清单,同一份二进制不会在运行期跨渠道切换。 +- 开发态(`npm run agc` / `agc:serve` 由 Vite dev server 提供前端)不检查更新、不显示更新入口,也不下载任何更新包。 + +### 失败、重试与幂等 + +- 清单请求失败均静默忽略,不阻塞客户端启动:网络错误、TLS 错误、404(渠道尚未发布版本)、格式非法、渠道没有当前平台条目、远端版本不高于当前版本。 +- 同一客户端生命周期内只自动检查一次;手动检查可强制刷新。 +- 签名校验失败、下载中断或写入失败必须失败关闭:删除临时文件、不启动安装程序,并给出可读错误文案;不接受「校验失败但继续安装」。 +- 重复点击下载或安装不产生并发安装;安装开始后客户端不再接受新的更新操作。 + +### 权限、归属与数据边界 + +- 更新能力通过 Tauri capability 显式授予客户端主窗口,其它窗口(调试窗口等)不得授予。 +- 客户端只允许访问渠道清单声明的地址,只允许安装清单声明且签名校验通过的对象。 +- 清单与安装包在 OSS 上保持公开可读;签名私钥与 OSS 凭据只存在于构建环境(Jenkins 凭据、本机发布配置),不写入仓库、日志、构建产物或客户端包。 +- 客户端不记录更新地址以外的敏感信息;失败文案不回显凭据、绝对路径或响应正文。 + +## 契约与迁移 + +- 清单格式(Tauri updater v2): ```json { - "version": "0.1.13", - "downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe", - "sha256": "<64位十六进制摘要>", - "size": 123456789, - "releaseNotes": "修复与改进" + "version": "0.1.48", + "notes": "发布说明,可为空", + "pub_date": "2026-09-17T00:00:00Z", + "platforms": { + "windows-x86_64": { + "signature": "<.sig 文件内容>", + "url": "https:///agc/dev-win/0.1.48/<安装包文件名>" + } + } } ``` -`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后通过 Windows UAC 提权启动 NSIS 静默安装并退出旧客户端。 +- 渠道与平台映射: -## 启动与失败策略 +| 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 | +| --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ | +| `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` | +| `dev-mac` | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | -- 检查挂在 `WindowChrome` 根组件,覆盖首页、工作台和调试窗口;网络错误、格式错误或版本不高于当前版本均静默忽略,不阻塞客户端启动。 -- 更新请求使用单例 Promise,React StrictMode 或同一窗口重复挂载不会重复请求。 -- Tauri HTTP capability 与 CSP 仅放行默认 OSS 域名;若更换域名,需同步更新 `capabilities/main.json`、`tauri.conf.json` 和发布环境配置。 +- 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。 +- macOS 使用 universal 包:`dev-mac` 按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),清单把同一个 `.app.tar.gz` 与同一个签名分别写入 `darwin-aarch64` 与 `darwin-x86_64`,升级后仍是 universal 包。这是 Tauri 官方发布工具对 universal 产物的既有写法。 +- 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。 +- 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 +- 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 +- 迁移(旧协议 → 渠道清单): + - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 + - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 -## 发布约定 +## 构建与发布 -当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会先读取 -`VITE_AGC_UPDATE_MANIFEST_URL`(默认 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`)的 -`latest.json`,取本地与 OSS 的较高版本并递增一个 patch,然后同步更新 package、Tauri 和 Cargo -版本后再向 Tauri 传入 `--target x86_64-pc-windows-msvc` 构建。OSS 清单首次不存在时按本地版本递增; -OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆盖线上版本。构建完成后自动扫描 `.exe` -安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` -生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 -Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 -OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过 -`AGC_UPDATE_RELEASE_NOTES` 写入发布说明,支持多行文本且保留内部换行;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 +- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 +- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 +- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 +- 归档证据:安装包、`.sig`、渠道清单与源码 commit。 -每次发布安装包上传完成后,再使用 ossutil 的 `--force` 覆盖上传同一目录生成的 `latest.json`,确保固定的 latest 指针和 `downloadUrl` 指向已存在的 OSS 对象;未显式强制覆盖时,ossutil 在目标已存在时会交互询问并按默认值跳过,不能作为 Jenkins 非交互发布方式。清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 +## 验收标准与证据 -如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`, -先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 -`agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` -覆盖;本机执行时凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 +已获得的证据: -## Jenkins Windows 构建节点 +| 条款 | 验收方式 | 证据 | +| ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------- | +| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | +| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| 缺签名时失败关闭 | 同上 | 通过 | +| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | +| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | -AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,当前节点标签为 -`windows && win2022`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm -10.9.7、Rust 1.96.0、Visual Studio Build Tools(MSVC 与 Windows SDK)、Git 和 ossutil; -Jenkins Agent 服务必须能在同一用户环境中找到这些命令。Tauri Windows bundler 使用 -`tauri.windows.conf.json` 中的 `bundle.useLocalToolsDir: true`,把固定版本的 NSIS 工具缓存到 -`src-tauri/target/.tauri/NSIS`,不依赖 Jenkins 服务账户的 `%LOCALAPPDATA%\tauri` 或 PATH 中的系统 NSIS。 -Jenkins Checkout 的 `git clean -fdx` 会清理该构建目录,因此每次全新工作区可能重新下载 NSIS;这只影响构建耗时,不改变工具来源或执行权限要求。 -流水线参数 `AGC_UPDATE_RELEASE_NOTES` 使用 Jenkins `text` 类型,可直接输入多行发布说明;执行根 workspace 的 `npm ci`,然后调用 -`npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 -流水线会将未导出的空参数按空字符串处理:`COMMIT_HASH` 留空时沿用 Jenkins SCM 当前提交,`OSSUTIL_BIN` 留空时使用节点 PATH 中的 `ossutil`,不会因 PowerShell 对空环境变量调用 `.Trim()` 而提前失败。 +待执行证据(首次渠道发布后回填): -Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAccessKeyId` 和 -`AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志; -本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予 -Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins -上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。Job 参数 -`AGC_RELEASE_VERSION` 留空时自动递增,填写后会使用指定版本并更新对应的 `latest.json`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。 +| 条款 | 验收方式 | 证据 | +| ---------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| 清单与对象布局符合渠道约定 | `ossutil ls oss://agc-dev/agc/dev-win//`;`ossutil cat .../dev-win/latest.json` | 待执行:URL 指向已存在安装包,签名与 `.sig` 内容一致 | +| 旧协议迁移桥 | `ossutil cat oss://agc-dev/agc/latest.json` | 待执行:`sha256` / `size` 与同一安装包匹配 | +| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | +| 签名校验失败拒绝安装 | 渠道清单签名与实际安装包不匹配时的表现 | 待执行(需要真实渠道清单) | + +## 未决问题与决策 + +已决策: + +- macOS 采用 universal 包,同一产物同时挂 `darwin-aarch64` 与 `darwin-x86_64` 两个清单键(见「契约与迁移」)。 +- 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 +- 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 +- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 + +待办: + +- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0d1af4ab8..38285c41a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -70,7 +70,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。开发态客户端不检查更新:启动器给 AGC Vite 注入 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=0`,客户端不请求 OSS 更新清单、也不显示更新入口;需要联调更新流程时显式传 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`。 AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 3e304cf63..5bd728af0 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -21,8 +21,9 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') - string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch') - text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入 latest.json 的发布说明') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch') + choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -123,11 +124,14 @@ pipeline { withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), + string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY'), + string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'), ]) { withEnv([ "PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}", "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", + "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' @@ -153,14 +157,14 @@ pipeline { stage('Archive release') { steps { - archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,.jenkins-source-commit', fingerprint: true + archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true } } } post { success { - echo 'AGC Windows x64 安装包已构建并上传 OSS。' + echo "AGC ${params.AGC_UPDATE_CHANNEL} 渠道安装包、签名与渠道清单已构建并上传 OSS。" } } } diff --git a/package-lock.json b/package-lock.json index 4f7ddc219..ccb4be9ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,7 @@ "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", + "@tauri-apps/plugin-updater": "2.11.0", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", @@ -8093,6 +8094,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz", + "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -26459,6 +26469,7 @@ "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", + "@tauri-apps/plugin-updater": "2.11.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", @@ -28226,6 +28237,14 @@ "@tauri-apps/api": "^2.11.0" } }, + "@tauri-apps/plugin-updater": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz", + "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==", + "requires": { + "@tauri-apps/api": "^2.11.0" + } + }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", From aec9568c39e5fb2d5f881452782a3307145a7ae0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 17:47:16 +0800 Subject: [PATCH 13/68] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8F=82=E8=80=83=E7=B4=A0=E6=9D=90=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 referenceAssetIds 原生入参:start_local_project_asset_generation 与 generate_local_project_asset 经 prepare_local_project_asset_generation 统一收口当前项目 manifest 图片素材 id PlatformArtAssetGenerationOptions 增加 reference_asset_ids,GUI、agent 桥、media 观察与 Direct 运行时构造点显式传空 参考解析固定规范图前置在前、用户参考按给出顺序随后,按远端资源 ID 去重,普通生成总量 5 张、有规范前置 4 张用户参考,图集拒绝额外参考而非静默丢弃 参考素材复用 manifest 到本地文件、图片解码、内容 hash、当前账号 binding 上传的既有通道,历史账号遗留远端 ID 不再复用 retained 阶段与清单校验改为与提交同一套请求合同:icon-spec、ui-prototype、game-background 允许正确引用,art-spritesheet 仍只接受唯一规范引用 修复 Part::file_name 的生命周期错误与 4 处测试构造点少传参考参数 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 16 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 1 + .../src-tauri/src/agent/generation.rs | 9 +- .../src/agent/generation/canvas_generation.rs | 409 +++++++++++++++--- .../src/agent/runtime_tools/media.rs | 2 + .../src-tauri/src/asset_generation_tasks.rs | 4 + .../src-tauri/src/commands.rs | 45 +- .../src-tauri/src/project/manifest.rs | 23 +- .../src-tauri/src/tests/project.rs | 6 + 9 files changed, 432 insertions(+), 83 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 619fd4162..87e4b3174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity( .to_string(), reference_resource_ids: asset.source.reference_resource_ids.clone(), }; + // 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有 + // 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。 + let references_match_contract = + crate::agent::platform_art_runtime_references_match_request_contract( + &identity.reference_resource_ids, + expected_kind, + ); let lineage_matches = match expected_reference_source { Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), - None => identity.reference_resource_ids.is_empty(), + None => true, }; - lineage_matches.then_some(identity) + (references_match_contract && lineage_matches).then_some(identity) }) } @@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source( source: &DirectTaonierArtAssetIdentity, derived: &DirectTaonierArtAssetIdentity, ) -> bool { - let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + // 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后, + // 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。 + let Some(remote_reference_id) = derived.reference_resource_ids.first() else { return false; }; if derived.canvas_project_id == source.canvas_project_id @@ -3386,6 +3395,7 @@ async fn generate_direct_taonier_art_asset_at( slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 705da3ecb..19858c852 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2262,6 +2262,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) slice_mode, grid_x, grid_y, + reference_asset_ids: Vec::new(), }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e52bbcfea..1e1600dbe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, - platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, - PLATFORM_ART_ASSET_GENERATION_KINDS, + normalize_platform_art_reference_asset_ids, platform_art_asset_art_spec, + platform_art_asset_output_extension_matches, + platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f545cc279..a544ba9db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -420,6 +420,16 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) slice_mode: Option, pub(crate) grid_x: Option, pub(crate) grid_y: Option, + /// 本次生成用作参考的**当前项目已登记图片素材 id**(manifest `assets[].id`)。 + /// + /// 只接受当前项目 manifest 身份:路径、远端 `resourceId` / `objectKey` 与跨项目素材都会在 + /// [`resolve_platform_art_generation_references_at`] 解析阶段被拒绝,素材内容再经本地文件、 + /// 图片解码与内容 hash 换成**当前账号**绑定下的远端资源 ID。 + /// + /// 它**刻意不进** standalone 动作指纹([`StandalonePlatformArtGenerationFingerprintMaterial`]): + /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 + /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 + pub(crate) reference_asset_ids: Vec, } impl Default for PlatformArtAssetGenerationOptions { @@ -435,10 +445,19 @@ impl Default for PlatformArtAssetGenerationOptions { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), } } } +/// 普通图片生成合并规范图与用户参考后的**总参考上限**,沿用图片生成 API 已有上限。 +pub(crate) const PLATFORM_ART_MAX_REFERENCE_IMAGES: usize = 5; +/// 有规范图前置时允许的用户参考上限:规范图本身占 1 张,总量仍不超过 +/// [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +pub(crate) const PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC: usize = 4; +/// 单个参考素材 id 的长度上限(manifest 资产 id 是稳定短标识,不是路径)。 +const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; + /// 「无源生成图片类素材」参数化通道放行的 kind 目录。 /// /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, @@ -473,6 +492,67 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option }) } +/// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 +/// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { + matches!( + asset_kind, + "ui-prototype" | "game-background" | "art-spritesheet" + ) +} + +/// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { + asset_kind != "art-spritesheet" +} + +/// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 +/// +/// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 +/// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 +pub(crate) fn normalize_platform_art_reference_asset_ids( + asset_kind: &str, + asset_ids: &[String], +) -> Result, String> { + if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { + return Err("透明美术图集只接受规范图引用,不接受用户参考素材".to_string()); + } + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + continue; + } + if asset_id.chars().count() > PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + || asset_id.chars().any(char::is_control) + || asset_id.contains('/') + || asset_id.contains('\\') + || asset_id.contains("://") + { + return Err(format!( + "参考素材只接受当前项目已登记素材 ID,不接受路径或远端资源 ID:{asset_id}" + )); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + } + } + if platform_art_asset_kind_requires_canonical_spec_reference(asset_kind) { + if normalized.len() > PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC { + return Err(format!( + "有规范图前置的生成最多 {} 张用户参考素材", + PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC + )); + } + } else if normalized.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "普通图片生成最多 {} 张参考素材", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(normalized) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -1602,6 +1682,38 @@ struct CanonicalArtSpecUploadTicket { form_fields: BTreeMap, } +/// 本次生成请求的全部参考资源身份。 +struct PlatformArtGenerationReferences { + /// 规范图前置引用:需要规范图的生成类型必定存在,其余类型为 `None`。 + canonical: Option, + /// 去重后的完整引用顺序:规范图在前,用户参考随后。它就是请求里的 `referenceImageSrcs`。 + ordered: Vec, +} + +/// 参考素材上传到平台时使用的文件名:由 manifest 素材的本地路径基名派生,只保留 ASCII 安全字符。 +/// +/// 同一素材路径不变时文件名稳定,平台对象键不会随重试漂移;无法派生时退回 +/// `reference.<媒体扩展名>`,扩展名由媒体类型决定,保持与 `contentType` 一致。 +fn platform_art_reference_upload_file_name(source: &GameCreationAppAssetManifestEntry) -> String { + let extension = infer_file_extension(Some(&source.local_path), &source.media_type); + let stem = Path::new(&source.local_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| { + stem.chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .collect::() + }) + .unwrap_or_default(); + if stem.is_empty() { + return format!("reference.{extension}"); + } + format!("{stem}.{extension}") +} + +/// 规范图前置的生成类型解析当前账号的规范图引用。 async fn canonical_art_spec_reference_at( root: &Path, client: &reqwest::Client, @@ -1622,17 +1734,78 @@ async fn canonical_art_spec_reference_at( "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() })?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest.project_id, + expected_canvas_project_id, + source, + ) + .await +} + +/// 用户参考素材(当前项目 manifest `assets[].id`)解析当前账号的远端资源 ID。 +/// +/// 只接受**当前项目**清单里的图片素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 +async fn manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + asset_id: &str, +) -> Result { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))?; + if !source.media_type.starts_with("image/") { + return Err(format!( + "参考素材必须是图片,不能引用 {}:{}", + source.media_type, asset_id + )); + } + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest.project_id, + expected_canvas_project_id, + source, + ) + .await +} + +/// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 +/// +/// 复用既有 manifest → 安全文件路径 → 图片解码 → 内容 hash → 当前账号 binding/上传 → +/// 远端 resource ID 流程:binding 存在时直接复用,缺失时从本地正式文件重新上传并登记, +/// 绝不用当前 token 探测或发送历史账号的 project/resource ID。 +async fn upload_manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + manifest_project_id: &str, + expected_canvas_project_id: &str, + source: &GameCreationAppAssetManifestEntry, +) -> Result { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` 门禁, + // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 + enforce_project_permission_policy(root, "asset.upload")?; + let file_name = platform_art_reference_upload_file_name(source); let source_path = resolve_local_project_path(root, &source.local_path)?; if !source_path.is_file() { - return Err( - "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" - .to_string(), - ); + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); } - let bytes = - fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -1642,19 +1815,19 @@ async fn canonical_art_spec_reference_at( )?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } let principal_key = - external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + external_editor_project_binding_key_sha256(manifest_project_id, &principal)?; let resource_binding_key = external_editor_resource_binding_key_sha256( - &manifest.project_id, + manifest_project_id, &principal_key, expected_canvas_project_id, &source_identity, @@ -1665,14 +1838,14 @@ async fn canonical_art_spec_reference_at( access.validate_frozen_session()?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 @@ -1695,57 +1868,57 @@ async fn canonical_art_spec_reference_at( "pathSegments": [ "editor", "account-scoped-bindings", - manifest.project_id.as_str(), + manifest_project_id, source_identity.source_sha256.as_str() ], - "fileName": "art-spec.png", + "fileName": file_name.as_str(), "contentType": source.media_type, "access": "private", "maxSizeBytes": bytes.len(), "successActionStatus": 204, })), - "创建当前账号规范图上传凭证", + "创建当前账号参考图上传凭证", ) .await?; access.validate_frozen_session()?; let upload = external_editor_response_data(&ticket_payload) .get("upload") .or_else(|| ticket_payload.pointer("/data/upload")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + .ok_or_else(|| "当前账号参考图上传凭证缺少 upload".to_string())?; let ticket = CanonicalArtSpecUploadTicket { host: json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 host".to_string())?, bucket: json_string_field(upload, "bucket") - .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 bucket".to_string())?, object_key: json_string_field(upload, "objectKey") - .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 objectKey".to_string())?, success_action_status: upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证 successActionStatus 无效".to_string())?, form_fields: upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .ok_or_else(|| "当前账号参考图上传凭证缺少 formFields".to_string())? .iter() .map(|(key, value)| { value .as_str() .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + .ok_or_else(|| "当前账号参考图上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?, }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1754,18 +1927,20 @@ async fn canonical_art_spec_reference_at( form.text(key.clone(), value.clone()) }); let part = Part::bytes(bytes.clone()) - .file_name("art-spec.png") + // `Part::file_name` 只接受 `'static` 名字:这里必须交出所有权,借用会让临时串在 + // 请求发出前就结束生命周期。 + .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号规范图失败".to_string())?; + .map_err(|_| "上传当前账号参考图失败".to_string())?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( - "上传当前账号规范图失败:HTTP {}", + "上传当前账号参考图失败:HTTP {}", upload_response.status().as_u16() )); } @@ -1787,19 +1962,19 @@ async fn canonical_art_spec_reference_at( "assetKind": source.kind, "accessPolicy": "private", })), - "确认当前账号规范图上传", + "确认当前账号参考图上传", ) .await?; access.validate_frozen_session()?; let asset_object = external_editor_response_data(&confirm_payload) .get("assetObject") .or_else(|| confirm_payload.pointer("/data/assetObject")) - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObject".to_string())?; if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + return Err("当前账号参考图确认响应 objectKey 不一致".to_string()); } let asset_object_id = json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObjectId".to_string())?; let resource_payload = external_editor_json_request( client .post(format!( @@ -1828,7 +2003,7 @@ async fn canonical_art_spec_reference_at( "localAssetId": source.id, }, })), - "登记当前账号规范图项目资源", + "登记当前账号参考图项目资源", ) .await?; let post_response_session = access.validate_frozen_session(); @@ -1837,9 +2012,9 @@ async fn canonical_art_spec_reference_at( resource_data.get("resource").unwrap_or(resource_data), "resourceId", ) - .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + .ok_or_else(|| "当前账号参考图项目资源响应缺少 resourceId".to_string())?; let binding = new_external_editor_resource_binding( - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, @@ -1855,6 +2030,61 @@ async fn canonical_art_spec_reference_at( Ok(remote_resource_id) } +/// 解析本次生成请求的全部参考资源身份。 +/// +/// 顺序与上限是请求合同的一部分: +/// +/// - 规范图前置的生成类型必须先解析出当前账号的规范图引用; +/// - 用户参考来自 `options.reference_asset_ids`(当前项目 manifest `assets[].id`),按给出顺序 +/// 逐个换成当前账号的远端资源 ID,并按远端 ID 去重; +/// - 图集类型只接受单规范引用,用户参考在这里被**拒绝**而不是静默丢弃; +/// - 合并后总数不超过 [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +async fn resolve_platform_art_generation_references_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( + &options.asset_kind, + &options.reference_asset_ids, + )?; + let canonical = + if platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind) { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; + let mut ordered = Vec::new(); + if let Some(reference) = canonical.as_ref() { + ordered.push(reference.clone()); + } + for asset_id in &user_reference_asset_ids { + let reference = manifest_asset_remote_reference_at( + root, + client, + access, + expected_canvas_project_id, + asset_id, + ) + .await?; + if !ordered.iter().any(|existing| existing == &reference) { + ordered.push(reference); + } + } + if ordered.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "图片生成参考素材最多 {} 张(含规范图)", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(PlatformArtGenerationReferences { canonical, ordered }) +} + fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // External Editor validates each description independently (currently at // 200 Unicode characters). Keep the gameplay context short enough that a @@ -2370,6 +2600,44 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ .await } +/// 保留账本里的参考集合是否符合该生成类型的**请求合同**。 +/// +/// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 +/// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: +/// +/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 +/// +/// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 +/// [`resolve_platform_art_generation_references_at`],这里只回答「已有账本/清单里的这份参考集合, +/// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 +pub(crate) fn platform_art_runtime_references_match_request_contract( + reference_resource_ids: &[String], + expected_asset_kind: &str, +) -> bool { + if reference_resource_ids + .iter() + .any(|reference| reference.trim().is_empty()) + { + return false; + } + // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 + if expected_asset_kind != "game-background" + && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + { + return false; + } + if !platform_art_asset_kind_accepts_user_reference_assets(expected_asset_kind) { + return reference_resource_ids.len() == 1; + } + if platform_art_asset_kind_requires_canonical_spec_reference(expected_asset_kind) { + return (1..=PLATFORM_ART_MAX_REFERENCE_IMAGES).contains(&reference_resource_ids.len()); + } + reference_resource_ids.len() <= PLATFORM_ART_MAX_REFERENCE_IMAGES +} + pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, @@ -2390,24 +2658,35 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { + // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: + // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 "icon-spec" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.is_empty() + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "icon-spec", + ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } "game-background" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "game-background", + ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } "art-spritesheet" => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "art-spritesheet", + ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") } @@ -2647,23 +2926,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); } - if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - let current_reference = canonical_art_spec_reference_at( + { + // 恢复的判据是「本次请求解析出的当前账号引用」与账本快照逐一相符:规范图身份漂移 + // 与用户参考变化都必须被识别,不能把上一次请求的参考当成本次请求的参考恢复。 + let current_references = resolve_platform_art_generation_references_at( root, &client, &binding_access, &snapshot.canvas_project_id, + options, ) .await .map_err(|error| { format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化生成请求的当前规范图与参考素材身份:{error}" ) })?; - if snapshot.reference_resource_ids != [current_reference] { + if snapshot.reference_resource_ids != current_references.ordered { if platform_art_generation_runtime_status(&state) == "accepted" { if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) @@ -2695,7 +2974,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } } return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份或用户参考素材与已持久化请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } @@ -2777,22 +3056,17 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - Some( - canonical_art_spec_reference_at( - root, - &client, - &binding_access, - &canvas_context.project_id, - ) - .await?, - ) - } else { - None - }; + // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, + // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 + let references = resolve_platform_art_generation_references_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + options, + ) + .await?; + let canonical_reference = references.canonical.clone(); let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -2836,7 +3110,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "generationInputs": { "artSpec": platform_art_asset_art_spec(options), }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "referenceImageSrcs": references.ordered.clone(), "canvasCompletion": { "title": options.asset_label, "placeholder": external_canvas_placeholder(&options.aspect_ratio), @@ -3015,7 +3289,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at endpoint.to_string(), generation_kind.to_string(), is_canonical_art_spritesheet, - canonical_reference.into_iter().collect::>(), + references.ordered.clone(), generation_prompt.clone(), ) }; @@ -8326,6 +8600,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -10373,6 +10648,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11277,6 +11553,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11741,6 +12018,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12350,6 +12628,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 6f7aa3441..34c4b3e21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -577,6 +577,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), grid_x, grid_y, + reference_asset_ids: Vec::new(), }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -628,6 +629,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), grid_x, grid_y, + reference_asset_ids: requested_options.reference_asset_ids, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 91a9eae42..6807a6e28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -390,6 +390,9 @@ pub(crate) async fn start_local_project_asset_generation( image_size: Option, asset_name: Option, output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -400,6 +403,7 @@ pub(crate) async fn start_local_project_asset_generation( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index f5724810b..832517ff6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4610,6 +4610,7 @@ pub(crate) fn prepare_local_project_asset_generation( image_size: Option<&str>, asset_name: Option<&str>, output_path: Option<&str>, + reference_asset_ids: &[String], ) -> Result { let project_path = project_path.trim(); if project_path.is_empty() { @@ -4617,6 +4618,10 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, + // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 + let reference_asset_ids = + normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), prompt: local_project_asset_prompt(prompt)?, @@ -4650,6 +4655,7 @@ pub(crate) fn prepare_local_project_asset_generation( slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids, }, }) } @@ -4671,6 +4677,7 @@ pub(crate) async fn generate_local_project_asset( image_size: Option, asset_name: Option, output_path: Option, + reference_asset_ids: Option>, ) -> Result { let request = prepare_local_project_asset_generation( &project_path, @@ -4680,6 +4687,7 @@ pub(crate) async fn generate_local_project_asset( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -4698,7 +4706,16 @@ mod local_project_asset_generation_tests { use super::*; fn prepare(kind: &str, prompt: &str) -> Result { - prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None) + prepare_local_project_asset_generation( + "/tmp/project", + kind, + prompt, + None, + None, + None, + None, + &[], + ) } #[test] @@ -4738,6 +4755,7 @@ mod local_project_asset_generation_tests { Some("2K"), Some(" 主角图集 "), Some(" assets/hero.png "), + &[], ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -4765,8 +4783,17 @@ mod local_project_asset_generation_tests { #[test] fn invalid_toolbar_arguments_are_rejected_before_any_generation() { assert_eq!( - prepare_local_project_asset_generation("", "image", "要求", None, None, None, None) - .expect_err("empty project path"), + prepare_local_project_asset_generation( + "", + "image", + "要求", + None, + None, + None, + None, + &[] + ) + .expect_err("empty project path"), "项目路径不能为空" ); assert_eq!( @@ -4793,7 +4820,8 @@ mod local_project_asset_generation_tests { Some("4:3"), None, None, - None + None, + &[] ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -4806,7 +4834,8 @@ mod local_project_asset_generation_tests { None, Some("4K"), None, - None + None, + &[] ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -4819,7 +4848,8 @@ mod local_project_asset_generation_tests { None, None, Some("坏\u{7}名字"), - None + None, + &[] ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -4832,7 +4862,8 @@ mod local_project_asset_generation_tests { None, None, None, - Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), + &[] ) .expect_err("oversized output path"), "输出路径超出安全边界" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index a3fe7968f..d5c37f54f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset( } if task_id == "art-director" { - if !asset.source.reference_resource_ids.is_empty() { - return Err("统一视觉规范图不得声明派生资源引用".to_string()); + // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 + // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 + // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / + // resourceId / PNG 解码等身份判据全部保持不变。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { + return Err(format!( + "统一视觉规范图的参考集合不符合请求合同:{expected_path}" + )); } return Ok(()); } @@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { + // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: + // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); - }; + } + let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); let original_provenance_matches = canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; let rebound_local_source_matches = if original_provenance_matches { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 2270ca448..fb0915dce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1079,6 +1079,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }, ) .await; @@ -2656,6 +2657,7 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("2K".to_string()), Some("工具栏图片".to_string()), None, + None, ) .await .expect("toolbar asset generation"); @@ -2722,6 +2724,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s None, Some("视觉规范图".to_string()), None, + None, ) .await .expect("toolbar spec generation"); @@ -2788,6 +2791,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -2811,6 +2815,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("1K".to_string()), Some("游戏首版图集".to_string()), None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -5499,6 +5504,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", From 1f5b7b250a86b2cab45e12044ad5bdcf3d8cf079 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:03:15 +0800 Subject: [PATCH 14/68] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E4=BD=8D=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=88=B0=E6=A8=A1=E6=9D=BF=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页「灵感推荐」已被模板库推荐位替换,用例改为断言模板库推荐卡片、已下载徽标与共 N 个模板计数 - 用例改为断言点击推荐位进入模板库页面,且不触发下载或由模板创建项目 - 保留不请求已退役展示流接口的断言 --- .../tests/appSurface/home.suite.ts | 100 +++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..47e386b36 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -182,24 +182,98 @@ export function registerClientHomeTests() { ); }); - it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => { + it('shows the home template recommendations and opens the library without creating a project', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const templateLibrarySnapshot = { + schemaVersion: 'game-template-library.v1', + library: 'genarrative-official', + libraryVersion: 3, + updatedAt: '2026-09-17T00:00:00Z', + fetchedAtMillis: 1, + source: 'remote', + templates: [ + { + id: 'lane-defense', + title: '星际防线', + summary: '塔防原型', + tags: ['塔防'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip', + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png', + coverWidth: 320, + coverHeight: 180, + installed: true, + installedVersion: '1.0.0', + installedAtMillis: 2, + }, + { + id: 'cozy-farm', + title: '悠然农场', + summary: '经营原型', + tags: ['经营'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip', + zipSizeBytes: 4096, + zipSha256: 'b'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png', + coverWidth: 320, + coverHeight: 180, + installed: false, + installedVersion: null, + installedAtMillis: null, + }, + ], + }; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'fetch_game_template_library') { + return templateLibrarySnapshot; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); - const inspiration = screen.getByLabelText('灵感推荐'); - const inspirationImages = within(inspiration).getAllByRole('button', { - name: /查看灵感图片/, + // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 + const recommendations = await screen.findByLabelText('模板库推荐'); + const recommendationCards = within(recommendations).getAllByRole('button', { + name: /^查看模板 /u, }); - expect(inspirationImages.length).toBeGreaterThan(0); - expect(inspiration.closest('.overflow-y-auto')).not.toBeNull(); - expect(screen.queryByText('暂无灵感')).toBeNull(); + expect(recommendationCards.length).toBe(2); + expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull(); - fireEvent.click(inspirationImages[0]!); - const preview = screen.getByRole('dialog', { name: '查看灵感图片' }); - fireEvent.click(screen.getByAltText('放大的灵感图片')); - expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull(); - fireEvent.click(preview); - expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull(); + fireEvent.click(recommendationCards[0]!); + + // 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。 + const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个'); + expect(librarySummary).not.toBeNull(); + expect(screen.getByRole('button', { name: '返回' })).not.toBeNull(); + expect(screen.queryByLabelText('模板库推荐')).toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === 'download_game_template' || + command === 'create_automatic_local_game_project_from_template', + ), + ).toBe(false); await act(async () => { await Promise.resolve(); From 5e4ff54a9df8675b6dd1422a690a2163dfb6a3dc Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:04:21 +0800 Subject: [PATCH 15/68] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=86=85=E7=BD=AE=E7=9A=84=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=20package-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板 fixture 内的 package-lock.json 触发 workspace 守卫的「禁止嵌套 lockfile」规则,导致 master 门禁失败 - 其余五个模板都只提交 project 内容与 package.json,删除该文件与它们保持一致 - 模板 zip 由发布脚本现场打包并重新计算大小与哈希,仓库内没有引用该 lock 的脚本或测试 --- .../project/game/package-lock.json | 1138 ----------------- 1 file changed, 1138 deletions(-) delete mode 100644 apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json deleted file mode 100644 index 45ece2543..000000000 --- a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json +++ /dev/null @@ -1,1138 +0,0 @@ -{ - "name": "agc-game", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agc-game", - "dependencies": { - "phaser": "4.2.1" - }, - "devDependencies": { - "vite": "^6.2.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", - "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/phaser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", - "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", - "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} From 7edc452c6ac615f074a627069b480d3a5dfe5eb3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:08:38 +0800 Subject: [PATCH 16/68] =?UTF-8?q?=E9=87=8D=E6=9E=84AGC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=8F=9C=E5=8D=95=E4=B8=8E=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88#409=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录 --- .../resourceCanvasFocusModel.ts | 2 +- apps/ai-game-creator-shell/src/styles.css | 27 -- .../src/view/project-development/index.tsx | 90 +++-- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 26 +- .../projectResourceLiveIntegration.test.tsx | 9 +- .../resourceCanvasFloatingDismiss.test.tsx | 2 + .../tests/resourceVersionReplacement.test.tsx | 97 ++++- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 10 + .../【里程碑】AGC资源菜单收纳-2026-09-17.md | 31 ++ .../shared-memory/team-conventions.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + .../components/CanvasCardCornerActions.tsx | 67 ++++ .../src/components/OverflowActions.test.tsx | 206 ++++++++++ .../shared/src/components/OverflowActions.tsx | 237 ++++++++++++ packages/shared/src/components/index.ts | 2 + packages/shared/src/components/styles.css | 123 ++++++ ...ageCanvasSelectedLayerToolbarView.test.tsx | 44 +++ .../ImageCanvasSelectedLayerToolbarView.tsx | 359 +++++++++--------- .../ImageCanvasWorldView.test.tsx | 11 +- .../image-editor/ImageCanvasWorldView.tsx | 57 +-- vitest.config.ts | 7 + 22 files changed, 1068 insertions(+), 346 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md create mode 100644 packages/shared/src/components/CanvasCardCornerActions.tsx create mode 100644 packages/shared/src/components/OverflowActions.test.tsx create mode 100644 packages/shared/src/components/OverflowActions.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 92ba12136..a61125c41 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget( return Boolean( target.closest('.game-resource-card') && !target.closest( - 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', ), ); } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..ed82b7507 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7494,33 +7494,6 @@ iframe.preview-frame { visibility: visible; } -.game-resource-card-type-badge { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; - display: inline-flex; - max-width: calc(100% - 16px); - align-items: center; - min-width: 0; - padding: 4px 8px; - overflow: hidden; - border: 1px solid rgb(255 255 255 / 72%); - border-radius: 999px; - background: rgb(75 48 38 / 84%); - color: #fff; - font-size: 10px; - font-weight: 900; - line-height: 1; - letter-spacing: 0.02em; - pointer-events: none; - text-overflow: ellipsis; - white-space: nowrap; - box-shadow: 0 8px 18px rgb(96 62 47 / 20%); - transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); - transform-origin: top right; -} - /* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。 左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色 把这条关系与光环联系起来。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..cd0e4276a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -15,6 +15,7 @@ import { SelectionOverlay, } from '@genarrative/image-canvas-react'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AtSign, Crosshair, @@ -42,7 +43,6 @@ import { Replace, RotateCcw, Search, - Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -695,6 +695,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize, activeMediaIdentity, onSelect, + onShowInfo, + onChangeType, + infoPressed, onPointerDown, onPointerMove, onPointerUp, @@ -724,6 +727,9 @@ const ResourceCard = memo(function ResourceCard({ cardSize: ResourceCanvasCardSize; activeMediaIdentity: string | null; onSelect: (resourceId: string, options?: { append?: boolean }) => void; + onShowInfo: (resource: ProjectResource) => void; + onChangeType: (assetId: string) => void; + infoPressed: boolean; onPointerDown: ( event: ReactPointerEvent, resource: ProjectResource, @@ -1072,13 +1078,19 @@ const ResourceCard = memo(function ResourceCard({ - - {cardTypeLabel} - + onChangeType(resource.manifestAssetId!) + : undefined + } + onInfoClick={() => onShowInfo(resource)} + /> {lineage ? ( // 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据 // (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。 @@ -1604,7 +1616,12 @@ export default function ProjectDevelopmentView({ const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); /** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */ - const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); + const [resourceInfoResourceId, setResourceInfoResourceId] = useState< + string | null + >(null); + const resourceInfoPanelOpen = + resourceInfoResourceId !== null && + selectedResourceIds[0] === resourceInfoResourceId; const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ @@ -1909,7 +1926,7 @@ export default function ProjectDevelopmentView({ */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); - setResourceInfoPanelOpen(false); + setResourceInfoResourceId(null); if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { return; } @@ -2333,7 +2350,9 @@ export default function ProjectDevelopmentView({ * 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。 */ useEffect(() => { - setResourceInfoPanelOpen(false); + setResourceInfoResourceId((current) => + current === selectedResourceId ? current : null, + ); }, [selectedResourceId]); const projectVersions = useMemo( () => manifest.versions ?? [], @@ -6262,6 +6281,16 @@ export default function ProjectDevelopmentView({ const showRunUnavailableHint = !runAvailable && !uiEditorRoute; + const showResourceCardInfo = useCallback( + (resource: ProjectResource) => { + handleResourceSelect(resource.id); + setResourceInfoResourceId((current) => + current === resource.id ? null : resource.id, + ); + }, + [handleResourceSelect], + ); + const renderResourceBookCard = useCallback( ( resource: ProjectResource, @@ -6319,6 +6348,9 @@ export default function ProjectDevelopmentView({ } activeMediaIdentity={activeCardMediaIdentity} onSelect={handleResourceSelect} + onShowInfo={showResourceCardInfo} + onChangeType={setResourceTypeAssetId} + infoPressed={resourceInfoResourceId === resource.id} onPointerDown={(event) => handleResourceCardPointerDown( event, @@ -6350,6 +6382,8 @@ export default function ProjectDevelopmentView({ handleResourceCardPointerMove, handleResourceCardPointerUp, handleResourceSelect, + showResourceCardInfo, + resourceInfoResourceId, resourceCardDragPreview, resourceCardPreviews, resourceReplacementLineageBadgeMap, @@ -7976,6 +8010,7 @@ export default function ProjectDevelopmentView({ ) || selectedResourceOpensUiEditor) ? ( 引用 ) : null} - {selectedResource ? ( - } - onClick={() => - setResourceInfoPanelOpen((open) => !open) - } - > - 信息 - - ) : null} {selectedResource?.manifestAssetId ? ( 编辑标签 ) : null} - {selectedResource?.manifestAssetId ? ( - } - onClick={() => - setResourceTypeAssetId( - selectedResource.manifestAssetId, - ) - } - > - 素材类型 - - ) : null} {selectedResource?.manifestAssetId ? ( setResourceInfoPanelOpen(false)} + onClose={() => setResourceInfoResourceId(null)} /> ) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..6de24b699 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -338,7 +338,7 @@ export function registerClientHomeTests() { expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { @@ -509,7 +509,7 @@ export function registerClientHomeTests() { ).not.toBeNull(); await openResourceBookCategory('项目版本'); expect( - await screen.findByRole('button', { name: /版本 1/ }), + await findResourceSelectButton('版本 1'), ).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 636ec3d74..f4b95519e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3817,21 +3817,10 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3863,11 +3852,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4075,10 +4064,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4093,9 +4079,7 @@ export function registerProjectWorkbenchFoundationTests() { .map((button) => button.getAttribute('aria-label')), ).toEqual([ '引用资源 bgm.mp3', - '信息', '编辑标签', - '素材类型', '重命名', '导出', '删除素材', diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..4234d82b8 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 22018450b..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
+ 信息
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => { } for (const id of [ 'play', + 'corner', 'video', 'input', 'editor', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..cfbc324f9 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => document.querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); + act(() => { menu.dispatchEvent(wheel); }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, + })); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); + fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), + screen.queryByRole('button', { name: '替换素材' }), ).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), @@ -560,7 +615,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(sourceToolbar, '替换素材'), ).not.toBeNull(); }); @@ -569,7 +624,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +730,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +764,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +790,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +842,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,8 +892,11 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), + ] .map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( @@ -848,9 +906,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +969,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1031,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(legacyToolbar, '替换素材'), ); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', @@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), + toolbarAction(finalToolbar, '替换素材'), ); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..96333cf43 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,10 @@ +# AGC 资源菜单收纳实施计划 + +对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 + +1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 +2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 +3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 +4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。 + +风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。 diff --git a/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md new file mode 100644 index 000000000..1c2ac43c9 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC资源菜单收纳-2026-09-17.md @@ -0,0 +1,31 @@ +# AGC 资源菜单收纳 + +- Version: 1 +- Status: implemented,本地自动化通过,待真实客户端验收 +- Date: 2026-09-17 +- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 范围与评审 + +仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。 + +## 验收 + +- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。 +- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。 +- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。 +- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。 + +## 产品结论与验收待办 + +正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。 + +完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。 + +## 验收证据 + +- `appSurface.test.ts`:450 项通过、20 项跳过。 +- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。 +- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。 +- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。 +- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 98662a4e2..6f64a3000 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,7 @@ ## 开发中 +- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..5bb7d5633 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -15,6 +15,8 @@ ## 资源卡选中工具栏与导出 +- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。 +- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。 - 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。 - AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。 - 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx new file mode 100644 index 000000000..4199f474c --- /dev/null +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -0,0 +1,67 @@ +import { Info } from 'lucide-react'; +import type { CSSProperties, Ref } from 'react'; +import { PlatformIconButton } from './PlatformIconButton'; + +/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ +export function CanvasCardCornerActions({ + kindLabel, + kindAriaLabel, + kindClassName, + infoLabel, + style, + kindRef, + onKindClick, + onInfoClick, + infoPressed, +}: { + kindLabel?: string | null; + kindAriaLabel?: string; + kindClassName?: string; + infoLabel: string; + style?: CSSProperties; + kindRef?: Ref; + onKindClick?: () => void; + onInfoClick: () => void; + infoPressed?: boolean; +}) { + return ( + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {kindLabel ? ( + { + if (onKindClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onKindClick(); + } + }} + > + {kindLabel} + + ) : null} + + ); +} diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx new file mode 100644 index 000000000..5f25b8d3a --- /dev/null +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -0,0 +1,206 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OverflowActions } from './OverflowActions'; +import { CanvasCardCornerActions } from './CanvasCardCornerActions'; + +afterEach(cleanup); +describe('操作收纳与卡片角标', () => { + it('具名入口可收纳全部动作,并优先向上展开', () => { + render( + + + + , + ); + const trigger = screen.getByRole('button', { name: '素材处理' }); + expect(screen.getAllByRole('button')).toHaveLength(1); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + left: 200, + right: 300, + top: 350, + bottom: 380, + width: 100, + height: 30, + x: 200, + y: 350, + toJSON: () => ({}), + }); + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(100); + fireEvent.mouseEnter(trigger); + const group = screen.getByRole('group', { name: '素材处理操作' }); + expect(parseFloat(group.style.top)).toBeLessThan(350); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['快速编辑', '改造']); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => { + const click = vi.fn(); + render( + + <> + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(6); + expect(screen.queryByText('六')).toBeNull(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['六', '七']); + expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByText('六')); + expect(click).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => { + vi.useFakeTimers(); + render( + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + fireEvent.mouseEnter(more); + fireEvent.click(more); + fireEvent.mouseLeave(more); + fireEvent.mouseEnter(screen.getByRole('group')); + vi.advanceTimersByTime(200); + expect(screen.getByText('二')).toBeTruthy(); + fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' }); + expect(screen.queryByRole('group')).toBeNull(); + expect(document.activeElement).toBe(more); + fireEvent.click(more); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('group')).toBeNull(); + vi.useRealTimers(); + }); + it('不溢出不显示更多,默认保持 Web 原样', () => { + const view = render( + + + + , + ); + expect(screen.queryByText('更多')).toBeNull(); + view.rerender( + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + , + ); + expect(screen.getAllByRole('button')).toHaveLength(10); + }); + it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => { + const view = render( + + + + , + ); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + expect(screen.getByRole('group')).toBeTruthy(); + view.rerender( + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + view.rerender( + + + + , + ); + expect(screen.queryByRole('group')).toBeNull(); + }); + it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => { + render( + + + + + + , + ); + const more = screen.getByRole('button', { name: '更多' }); + vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({ + left: window.innerWidth - 35, + right: window.innerWidth, + top: window.innerHeight - 40, + bottom: window.innerHeight - 10, + width: 35, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + // jsdom 没有真实布局,提供浮层测量以验证向上定位。 + const height = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockReturnValue(200); + fireEvent.click(more); + const panel = screen.getByRole('group'); + expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40); + screen.getByText('二').focus(); + fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' }); + expect(document.activeElement).toBe(screen.getByText('四')); + fireEvent.keyDown(screen.getByText('四'), { key: 'Home' }); + expect(document.activeElement).toBe(screen.getByText('二')); + height.mockRestore(); + vi.restoreAllMocks(); + }); + it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => { + const parent = vi.fn(), + kind = vi.fn(), + info = vi.fn(); + render( +
+ +
, + ); + const label = screen.getByRole('button', { name: '素材类型' }); + fireEvent.pointerDown(label); + fireEvent.click(label); + fireEvent.keyDown(label, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: '资源信息' })); + expect(kind).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx new file mode 100644 index 000000000..d79421308 --- /dev/null +++ b/packages/shared/src/components/OverflowActions.tsx @@ -0,0 +1,237 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' }; + +function flatten(nodes: ReactNode, prefix = ''): ReactNode[] { + return Children.toArray(nodes).flatMap((node, index) => + isValidElement(node) && node.type === Fragment + ? flatten(node.props.children, `${prefix}${index}.`) + : [ + isValidElement(node) + ? cloneElement(node, { key: `${prefix}${index}` }) + : node, + ], + ); +} + +function isDivider(node: ReactNode) { + return ( + isValidElement(node) && + (node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true') + ); +} + +/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */ +export function OverflowActions({ + children, + maxVisible = Infinity, + label = '更多', +}: { + children: ReactNode; + maxVisible?: number; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 }); + const trigger = useRef(null); + const panel = useRef(null); + const timer = useRef | null>(null); + const id = useId(); + const limit = Number.isFinite(maxVisible) + ? Math.max(0, Math.floor(maxVisible)) + : Infinity; + const nodes = flatten(children); + let count = 0; + const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit); + const primary = split < 0 ? nodes : nodes.slice(0, split); + while (primary.length && isDivider(primary[primary.length - 1])) + primary.pop(); + const overflow = + split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node)); + useEffect(() => { + if (overflow.length === 0) setOpen(false); + }, [overflow.length]); + const cancelClose = () => { + if (timer.current !== null) clearTimeout(timer.current); + timer.current = null; + }; + const show = () => { + cancelClose(); + setOpen(true); + }; + const scheduleClose = () => { + cancelClose(); + timer.current = setTimeout(() => { + if (!panel.current?.contains(document.activeElement)) setOpen(false); + }, 150); + }; + useEffect( + () => () => { + if (timer.current !== null) clearTimeout(timer.current); + }, + [], + ); + useLayoutEffect(() => { + if (!open || !overflow.length) return; + const update = () => { + const anchor = trigger.current?.getBoundingClientRect(); + if (!anchor) return; + const width = panel.current?.getBoundingClientRect().width ?? 200; + const height = panel.current?.scrollHeight ?? 320; + const below = window.innerHeight - anchor.bottom - 12; + const above = anchor.top - 12; + // 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。 + const down = above < 80 && below > above; + const maxHeight = Math.max(40, Math.min(360, down ? below : above)); + setPosition({ + left: Math.max( + 8, + Math.min(anchor.right - width, window.innerWidth - width - 8), + ), + top: down + ? anchor.bottom + 4 + : Math.max(8, anchor.top - Math.min(height, maxHeight) - 4), + maxHeight, + }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [open, overflow.length, children]); + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !trigger.current?.contains(event.target) && + !panel.current?.contains(event.target) + ) { + setOpen(false); + } + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('pointerdown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('pointerdown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + if (!overflow.length) return <>{children}; + return ( + <> + {primary} + + {open + ? createPortal( +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + if ((event.target as Element).closest('button:not(:disabled)')) + setOpen(false); + }} + onBlur={(event) => { + if ( + !event.currentTarget.contains(event.relatedTarget) && + event.relatedTarget !== trigger.current + ) + setOpen(false); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + trigger.current?.focus(); + } + if ( + ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key) + ) { + event.preventDefault(); + const buttons = Array.from( + event.currentTarget.querySelectorAll( + 'button:not(:disabled)', + ), + ); + const index = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const next = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? buttons.length - 1 + : (index + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + } + }} + > + {overflow} +
, + document.body, + ) + : null} + + ); +} diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index f2b1cee94..327a40757 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea'; // existing application adapters while keeping the public API product-neutral. export type { AgentMessageTone } from './AgentMessageContent'; export { AgentMessageContent } from './AgentMessageContent'; +export { CanvasCardCornerActions } from './CanvasCardCornerActions'; +export { OverflowActions } from './OverflowActions'; export type { ButtonProps as PlatformButtonProps, SwitchProps as PlatformSwitchProps, diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 2c969af7d..aa9301eec 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control { border-radius: 1.25rem 1.25rem 0 0; } } +/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */ +.shared-canvas-card-corners { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100% - 12px); + transform: scale( + var( + --image-canvas-editor-inverse-scale, + var(--genarrative-image-canvas-inverse-scale, 1) + ) + ); + transform-origin: top right; +} +.shared-canvas-card-kind { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 72%); + border-radius: 999px; + background: rgb(199 101 61 / 92%); + color: #fff; + font-size: 11px; + font-weight: 850; + line-height: 1; +} +.shared-canvas-card-kind[role='button'] { + cursor: pointer; +} +.shared-canvas-card-info { + display: grid; + flex: 0 0 22px; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgb(255 255 255 / 42%); + border-radius: 50%; + background: rgb(199 101 61 / 92%); + color: #fff; + cursor: pointer; +} +.shared-canvas-card-info:hover, +.shared-canvas-card-kind[role='button']:hover { + background: #b95d3a; +} +.shared-canvas-card-corners [role='button']:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} +.shared-overflow-trigger { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} +.shared-overflow-trigger:hover, +.shared-overflow-trigger[aria-expanded='true'] { + background: #f4e7df; +} +.shared-overflow-panel { + position: fixed; + z-index: 1500; + display: flex; + flex-direction: column; + gap: 3px; + width: 200px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 6px; + border: 1px solid #e7d5c8; + border-radius: 9px; + background: #fff; + color: #4b3026; + box-shadow: 0 10px 28px rgb(52 30 22 / 18%); +} +.shared-overflow-panel > button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 34px; + padding: 6px 10px; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +.shared-overflow-panel > button:hover, +.shared-overflow-panel > button:focus-visible { + background: #f4e7df; +} +.shared-overflow-panel > button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.shared-overflow-panel + > button.genarrative-image-canvas__chrome-button:not( + .genarrative-image-canvas__chrome-button--with-label + )::after { + content: attr(aria-label); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 8b5a462fc..a2f6a607e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -70,6 +70,50 @@ function renderSelectedToolbar( } describe('ImageCanvasSelectedLayerToolbarView', () => { + it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => { + const props = renderSelectedToolbar({ + maxVisibleActions: 5, + supportedActions: new Set([ + 'quick-edit', + 'character-animation', + 'download', + ]), + downloadLabel: '导出', + extraActions: ( + <> + + + + + ), + endActions: , + }); + const toolbar = screen.getByRole('toolbar'); + expect( + within(toolbar) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + const group = screen.getByRole('group', { name: '更多操作' }); + expect( + within(group) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['导出', '删除素材']); + fireEvent.click(within(group).getByRole('button', { name: '导出' })); + expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); + cleanup(); + const view = render(); + fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' })); + view.rerender( + , + ); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + }); it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => { const props = renderSelectedToolbar({ supportedActions: new Set(['quick-edit', 'download']), diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 6587025c0..f61909628 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,4 +1,5 @@ import { CanvasChromeButton } from '@genarrative/image-canvas-react'; +import { OverflowActions } from '@genarrative/shared/components'; import { Crop, Download, @@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction = | 'download'; type ImageCanvasSelectedLayerToolbarViewProps = { + maxVisibleActions?: number; /** * 宿主显式声明的可用动作集合。 * @@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean { } export function ImageCanvasSelectedLayerToolbarView({ + maxVisibleActions, supportedActions = null, extraActions, endActions, @@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="素材工具栏" onPointerDown={(event) => event.stopPropagation()} > - {canRedraw ? ( - } - onClick={() => onOpenRedrawPanel(selectedLayer)} - > - 改造 - - ) : null} - {canRedraw && hasExtraActions ? divider : null} - {extraActions} - {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} - {downloadAction} - {endActions} + + {canRedraw ? ( + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + ) : null} + {canRedraw && hasExtraActions ? divider : null} + {extraActions} + {(canRedraw || hasExtraActions) && hasEndActions ? divider : null} + {downloadAction} + {endActions} + ); } @@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({ aria-label="图片工具栏" onPointerDown={(event) => event.stopPropagation()} > - {showQuickEdit ? ( - } - onClick={() => onOpenQuickEditPanel(selectedLayer)} - > - 快速编辑 - - ) : null} - {showQuickEdit && hasEditingActions ? divider : null} - {showCropExpand ? ( - onOpenCropExpandPanel(selectedLayer)} - /> - ) : null} - {showRemoveBackground ? ( - onRemoveBackground(selectedLayer)} - /> - ) : null} - {showPerfectPixel ? ( - - ) : ( - - ) - } - // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 - // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 - // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 - // 与相邻的拆分图集按钮保持同一套门禁。 - disabled={ - isPersistingAssetKind || - isPerfectPixelProcessing || - isPerfectPixelPendingConfirmation - } - aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} - onClick={() => onPerfectPixel(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isPerfectPixelProcessing - ? '处理中' - : isPerfectPixelPendingConfirmation - ? '待确认' - : '完美像素'} - - - ) : null} - {showSplitIconSpritesheet ? ( - - ) : ( - - ) - } - disabled={isPersistingAssetKind || isSplittingIconSpritesheet} - aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} - onClick={() => onSplitIconSpritesheet(selectedLayer)} - > - - {isPersistingAssetKind - ? '保存中' - : isSplittingIconSpritesheet - ? '拆图中' - : '拆分图集'} - - - ) : null} - {showExtractUiDesign ? ( - } - onClick={() => onExtractUiDesignAssets(selectedLayer)} - > - 提取素材 - - ) : null} - {showCharacterAnimation ? ( - } - onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} - > - 生成动画 - - ) : null} - {canRedraw ? ( - <> - {showQuickEdit || hasEditingActions ? divider : null} + + {showQuickEdit ? ( } - onClick={() => onOpenRedrawPanel(selectedLayer)} + label="快速编辑" + title="快速编辑" + icon={} + onClick={() => onOpenQuickEditPanel(selectedLayer)} > - 改造 + 快速编辑 - - ) : null} - {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions - ? divider - : null} - {extraActions} - {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && - hasEndActions - ? divider - : null} - {downloadAction} - {endActions} + ) : null} + {showQuickEdit && hasEditingActions ? divider : null} + {showCropExpand ? ( + onOpenCropExpandPanel(selectedLayer)} + /> + ) : null} + {showRemoveBackground ? ( + onRemoveBackground(selectedLayer)} + /> + ) : null} + {showPerfectPixel ? ( + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + + ) : null} + {showSplitIconSpritesheet ? ( + + ) : ( + + ) + } + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} + onClick={() => onSplitIconSpritesheet(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + + + ) : null} + {showExtractUiDesign ? ( + } + onClick={() => onExtractUiDesignAssets(selectedLayer)} + > + 提取素材 + + ) : null} + {showCharacterAnimation ? ( + } + onClick={() => onOpenCharacterAnimationPanel(selectedLayer)} + > + 生成动画 + + ) : null} + {canRedraw ? ( + <> + {showQuickEdit || hasEditingActions ? divider : null} + } + onClick={() => onOpenRedrawPanel(selectedLayer)} + > + 改造 + + + ) : null} + {(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions + ? divider + : null} + {extraActions} + {(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) && + hasEndActions + ? divider + : null} + {downloadAction} + {endActions} + ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..bd03cd7ab 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) + .parentElement! .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? ( Date: Thu, 17 Sep 2026 18:10:34 +0800 Subject: [PATCH 17/68] =?UTF-8?q?=E5=9B=BE=E9=9B=86=E5=88=87=E7=89=87?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=B9=E4=B8=BA=E5=BF=85=E9=A1=BB=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=A3=B0=E6=98=8E=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E8=A6=81=E6=B1=82=20(#408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。 ## 变更 - 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。 - 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。 - AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。 - 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。 - 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。 - 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。 - 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。 ## 兼容性影响 省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。 ## 验证 - 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。 - AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。 - 前端:182 条定向测试与 `typecheck` 通过。 - 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。 - 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408 --- .../genarrative-external-editor-api/SKILL.md | 2 +- .../references/api-operations.md | 2 +- .../references/capability-routing.md | 4 +- .../scripts/genarrative_external_api.py | 18 +- .../deterministic-lane-defense-provider.mjs | 3 +- .../resources/agc-skills/manifest.json | 4 +- .../agc-skills/taonier-art-assets/SKILL.md | 17 +- .../references/platform-art-contract.md | 3 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 8 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 94 ++++++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 21 +- .../src/agent/generation/canvas_generation.rs | 122 ++++++++++- .../src/agent/runtime_tools/media.rs | 47 ++++ .../src-tauri/src/agent_native_tools.rs | 10 +- .../src-tauri/src/commands.rs | 5 +- .../src-tauri/src/config.rs | 82 ++++++- .../src-tauri/src/main.rs | 3 + .../src/tests/collaboration/delegation.rs | 1 + .../src-tauri/src/tests/project.rs | 1 + .../genarrative-external-v1.openapi.json | 17 +- ...施计划】图集切片模式显式决策-2026-09-17.md | 39 ++++ ...里程碑】图集切片模式显式决策-2026-09-17.md | 49 +++++ .../shared-memory/decision-log.md | 11 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 1 + ...辑器】画板图标素材生成入口设计-2026-06-15.md | 15 +- .../api-server/src/editor_agent/tool.rs | 4 +- .../api-server/src/editor_project_icon.rs | 203 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 26 ++- .../crates/api-server/src/external_mcp.rs | 2 +- ...CanvasEditorGenerationIntegration.test.tsx | 1 + ...ageCanvasGenerationSubmissionModel.test.ts | 1 + .../ImageCanvasGenerationSubmissionModel.ts | 3 + ...anvasGenerationSubmissionWorkflow.test.tsx | 1 + .../image-editor/editorProjectClient.test.ts | 35 +++ .../image-editor/editorProjectClient.ts | 23 +- 35 files changed, 796 insertions(+), 82 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index e4bbafbc2..93c8592bb 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. -- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output. +- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing. - For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs. - Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index a744d8589..25c807788 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. -`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result. +`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call. ## Common Values diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index af0ee338a..8a48a8347 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default). +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`. -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index c7c0ab33b..bc270fe38 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -617,6 +617,7 @@ class GenarrativeExternalClient: self, reference_id: str, icon_descriptions: list[str], + slice_mode: str, **fields: Any, ) -> Any: reference_id = normalize_optional_text(reference_id) @@ -625,6 +626,20 @@ class GenarrativeExternalClient: descriptions = [item.strip() for item in icon_descriptions if item.strip()] if not descriptions: raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item") + slice_mode = normalize_optional_text(slice_mode) + if slice_mode not in ("connected-components", "grid"): + raise GenarrativeApiError( + "slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default" + ) + grid_x = fields.get("gridX") + grid_y = fields.get("gridY") + if slice_mode == "grid": + if grid_x is None or grid_y is None: + raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY") + elif grid_x is not None or grid_y is not None: + raise GenarrativeApiError( + "slice_mode='connected-components' must not carry gridX/gridY" + ) label = fields.get("assetLabel", "图标图集") self._apply_canvas_session_fields(fields, label, 1024, 1024) fields.setdefault("screenColor", "auto") @@ -635,6 +650,7 @@ class GenarrativeExternalClient: **fields, "referenceId": reference_id, "iconDescriptions": descriptions, + "sliceMode": slice_mode, }, idempotency_key=idempotency_key, ) @@ -879,9 +895,9 @@ def _self_test() -> None: client.generate_icon_spritesheet( "editor-resource-spec", ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], + "connected-components", canvasSession=session, assetLabel="贪吃蛇透明图集", - sliceMode="connected-components", referenceId="must-not-override-explicit-reference", iconDescriptions=["不得覆盖显式图标描述"], ) diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index fed45aa02..c0a748ad3 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -724,6 +724,7 @@ function canvasAssetCall(agentId) { assetKind: 'art-spritesheet', assetLabel: '游戏首版核心美术素材', replaceExisting: false, + sliceMode: 'connected-components', }); } @@ -2691,7 +2692,7 @@ function createDeterministicCanvasFixture(apiKey) { 'deterministic spritesheet fixture', model: 'deterministic-canvas-v1', provider: 'deterministic-loopback', - sliceLayout: 'grid-2x2', + sliceMode: 'connected-components', spritesheetResource: { resourceId, projectId, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 1f7b5c124..385454c47 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.18", + "version": "2026-08-26.19", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec" + "sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534" }, { "name": "agc-web-game-development", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index d652b4436..c5c036c2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -19,9 +19,20 @@ image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for the complete game-art package and its canonical slices. -When `agc_generate_image` is used with `kind="art-spritesheet"`, pass -`sliceMode="connected-components"` (the default alpha-connectivity splitter) -or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried +When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is +required and has no default, so decide it explicitly: + +- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user + or brief actually names equal grid cells, fixed slots, or a concrete + column/row count; those dimensions must come from that requirement. +- Use `sliceMode="connected-components"` for free-form sheets, an open number of + subjects, or a request for one sheet; constrain the subject count with + `sliceCount` instead of inventing grid dimensions. + +Never assume `2x2` or any other grid to express "four kinds of assets", never +pass `gridX`/`gridY` together with `connected-components`, and never pass +`sliceMode` for another `kind`. The client rejects a missing, contradictory, or +misapplied declaration instead of choosing for you. The selected mode is carried through the client request and returned result; do not infer it from the number of slices. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md index bf0b74481..603b8077b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -15,7 +15,8 @@ - On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request. - `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent. - `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent. -- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata. +- For direct `agc_generate_image` spritesheet requests, `sliceMode` is required and has no default: `connected-components` selects alpha-connectivity detection, while `grid` uses the caller-provided `gridX` and `gridY` (1-32 each) and is only correct when the requirement names equal grid cells, fixed slots, or a concrete column/row count. `connected-components` must not carry `gridX`/`gridY`, and `sliceMode` must not be sent for another `kind`; the client rejects a missing, contradictory, or misapplied declaration instead of choosing a mode. The client preserves the selected mode and grid dimensions in the request identity and result metadata. +- The client-owned standard art package declares `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices are mapped to fixed usage paths: the platform must return exactly four slices or fail with an actionable `422` naming the recognized count, and the client refuses to write a usage manifest whose slice count is not exactly four. A `sliceMode` or grid-dimension echo that disagrees with the request also fails closed before local commit. - General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim. - `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities. - `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys. diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 619fd4162..945b16c1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3382,8 +3382,12 @@ 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, - slice_mode: None, + // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量, + // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。 + slice_count: (asset_kind == "art-spritesheet").then_some(4), + // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在 + // art-spritesheet 阶段显式声明连通域切分。 + slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), grid_x: None, grid_y: None, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index a517f1831..9e569eaea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2170,6 +2170,36 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result { }) } +/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 +fn validate_generate_image_slice_declaration( + kind: &str, + slice_mode: Option<&str>, + grid_x: Option, + grid_y: Option, + slice_count: Option, +) -> Result<(), String> { + if kind == "art-spritesheet" { + if slice_mode.is_none() { + return Err( + "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" + .to_string(), + ); + } + if slice_mode == Some("grid") && slice_count.is_some() { + return Err( + "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount".to_string(), + ); + } + return Ok(()); + } + if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { + return Err(format!( + "工具参数 sliceMode/gridX/gridY 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + )); + } + Ok(()) +} + async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { bridge_reject_unknown_fields( @@ -2263,6 +2293,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) { return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string()); } + validate_generate_image_slice_declaration( + kind.as_str(), + slice_mode.as_deref(), + grid_x, + grid_y, + None, + )?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, @@ -2309,6 +2346,14 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) "resources": resources, "warnings": generated.warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(), "sliceWarnings": generated.slice_warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(), + "sliceMode": generated.slice_mode, + "gridX": generated.grid_x, + "gridY": generated.grid_y, + "slicePaths": generated + .slices + .iter() + .map(|slice| slice.local_path.clone()) + .collect::>(), }) .to_string(), images, @@ -2743,6 +2788,55 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { + #[test] + fn generate_image_slice_declaration_is_explicit_and_self_consistent() { + let missing = + validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None) + .expect_err("art-spritesheet without sliceMode must fail closed"); + assert!(missing.contains("没有默认值"), "{missing}"); + assert!(missing.contains("connected-components"), "{missing}"); + + assert!(validate_generate_image_slice_declaration( + "art-spritesheet", + Some("connected-components"), + None, + None, + Some(4), + ) + .is_ok()); + assert!(validate_generate_image_slice_declaration( + "art-spritesheet", + Some("grid"), + Some(3), + Some(2), + None, + ) + .is_ok()); + let grid_with_count = validate_generate_image_slice_declaration( + "art-spritesheet", + Some("grid"), + Some(2), + Some(2), + Some(4), + ) + .expect_err("grid mode must not carry sliceCount"); + assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); + + let wrong_kind = validate_generate_image_slice_declaration( + "image", + Some("connected-components"), + None, + None, + None, + ) + .expect_err("slice declaration must stay scoped to art-spritesheet"); + assert!( + wrong_kind.contains("仅对 kind=art-spritesheet 生效"), + "{wrong_kind}" + ); + assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + } + #[test] fn remove_background_identity_preserves_default_and_distinguishes_options() { let legacy = "asset-1\0透明图"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 9c769fc5e..f30331490 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -273,20 +273,19 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "sliceMode": { "type": "string", "enum": ["connected-components", "grid"], - "default": "connected-components", - "description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分" + "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" }, "gridX": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式横向网格数量" + "description": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供" }, "gridY": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式纵向网格数量" + "description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供" } }, "required": ["prompt"], @@ -2393,6 +2392,20 @@ mod tests { image_tool["inputSchema"]["properties"]["sliceMode"]["enum"], json!(["connected-components", "grid"]) ); + assert!( + image_tool["inputSchema"]["properties"]["sliceMode"] + .get("default") + .is_none(), + "sliceMode must not advertise a default" + ); + assert!( + image_tool["inputSchema"]["properties"]["sliceMode"]["description"] + .as_str() + .is_some_and(|description| description.contains("没有默认值") + && description.contains("gridX") + && description.contains("connected-components")), + "sliceMode description must carry the explicit decision requirement" + ); let edit_tool = specs["tools"] .as_array() .expect("tool array") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f545cc279..a3b5be2a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1564,6 +1564,8 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { slice_warning: Option, slices: Vec, spritesheet_slice_mode: Option, + spritesheet_grid_x: Option, + spritesheet_grid_y: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -2468,6 +2470,31 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( if require_slices && options.asset_kind != "art-spritesheet" { return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); } + // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。 + if options.asset_kind == "art-spritesheet" { + let Some(slice_mode) = options.slice_mode.as_deref() else { + return Err( + "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components" + .to_string(), + ); + }; + if !matches!(slice_mode, "connected-components" | "grid") { + return Err(format!("图集切分模式不受支持:{slice_mode}")); + } + if slice_mode == "grid" && (options.grid_x.is_none() || options.grid_y.is_none()) { + return Err("sliceMode=grid 必须同时提供 gridX 与 gridY".to_string()); + } + if slice_mode == "connected-components" + && (options.grid_x.is_some() || options.grid_y.is_some()) + { + return Err( + "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明" + .to_string(), + ); + } + } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() { + return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string()); + } if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( runtime_context, ) && game_creator_agent_runtime_external_generation_exists( @@ -3106,6 +3133,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { None }; + let spritesheet_grid_x = if is_canonical_art_spritesheet { + json_u32_field(generated, "gridX") + } else { + None + }; + let spritesheet_grid_y = if is_canonical_art_spritesheet { + json_u32_field(generated, "gridY") + } else { + None + }; let resource_id = json_string_field(resource, "resourceId"); let task_id = if is_canonical_art_spritesheet { consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])? @@ -3164,6 +3201,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at slice_warning, slices, spritesheet_slice_mode, + spritesheet_grid_x, + spritesheet_grid_y, generation_route, generation_kind, reference_resource_ids, @@ -6494,6 +6533,50 @@ impl PlatformArtSliceContractRollback { } } +fn json_u32_field(value: &serde_json::Value, field: &str) -> Option { + value + .get(field) + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) +} + +/// 严格图集必须在请求与响应两端证明同一个切分声明:请求显式声明的模式必须被平台 +/// 原样回显,grid 的行列数也必须一致;否则本地无法判断实际按哪种方式切片。 +fn validate_platform_art_spritesheet_slice_declaration_matches_response( + options: &PlatformArtAssetGenerationOptions, + response_slice_mode: Option<&str>, + response_grid_x: Option, + response_grid_y: Option, +) -> Result<(), String> { + let requested = options + .slice_mode + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "图集生成缺少显式 sliceMode 声明,已拒绝提交严格图集".to_string())?; + let responded = response_slice_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "平台图集响应没有回显 sliceMode,无法证明切分方式与请求一致,已在本地落盘前拒绝提交" + .to_string() + })?; + if responded != requested { + return Err(format!( + "平台图集响应回显的 sliceMode={responded} 与请求 {requested} 不一致,已拒绝提交" + )); + } + if requested == "grid" + && (response_grid_x != options.grid_x || response_grid_y != options.grid_y) + { + return Err(format!( + "平台图集响应回显的 gridX/gridY={:?}/{:?} 与请求 {:?}/{:?} 不一致,已拒绝提交", + response_grid_x, response_grid_y, options.grid_x, options.grid_y + )); + } + Ok(()) +} + fn validate_strict_platform_art_spritesheet_contract( slices: &[PreparedPlatformArtAssetSlice], slice_warning: Option<&str>, @@ -6504,7 +6587,6 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, - spritesheet_slice_mode: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -6545,7 +6627,6 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - let _requested_slice_mode = spritesheet_slice_mode; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -7093,6 +7174,15 @@ fn commit_strict_platform_art_slices_at( "obstacles-and-scene", "feedback-effects", ]; + // 标准图集按用途位置映射到固定路径;数量不一致时必须失败关闭,不能靠 zip 静默截断 + // 或写入用途错位的切片清单。 + if slices.len() != usages.len() { + return Err(format!( + "标准美术图集必须正好包含 {} 张 canonical 切片,平台返回了 {} 张,已拒绝写入以避免用途错位", + usages.len(), + slices.len() + )); + } let mut generated = Vec::with_capacity(slices.len()); let mut registrations = Vec::with_capacity(slices.len()); let mut content_sha256s = Vec::with_capacity(slices.len()); @@ -7308,6 +7398,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( mut slice_warning, slices, spritesheet_slice_mode, + spritesheet_grid_x, + spritesheet_grid_y, generation_route, generation_kind, reference_resource_ids, @@ -7317,6 +7409,12 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( recover_existing_outputs, } = prepared; if require_complete_core_slices { + validate_platform_art_spritesheet_slice_declaration_matches_response( + options, + spritesheet_slice_mode.as_deref(), + spritesheet_grid_x, + spritesheet_grid_y, + )?; validate_strict_platform_art_spritesheet_contract( &slices, slice_warning.as_deref(), @@ -7327,7 +7425,6 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, - spritesheet_slice_mode.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -7688,6 +7785,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( })).collect::>(), "generationRoute": generation_route, "generationKind": generation_kind, + "sliceMode": spritesheet_slice_mode.clone(), + "gridX": spritesheet_grid_x, + "gridY": spritesheet_grid_y, "referenceResourceIds": reference_resource_ids, }), ); @@ -7697,6 +7797,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( Ok(GeneratedPlatformArtAsset { asset: registered, slices: generated_slices, + slice_mode: spritesheet_slice_mode.or_else(|| options.slice_mode.clone()), + grid_x: spritesheet_grid_x.or(options.grid_x), + grid_y: spritesheet_grid_y.or(options.grid_y), resource_id, asset_object_id, task_id, @@ -9789,7 +9892,6 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - None, &["art-spec-resource".to_string()], true, true, @@ -9814,7 +9916,6 @@ mod canvas_generation_tests { None, "route", "kind", - None, &[], false, false, @@ -9871,7 +9972,6 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - Some("grid"), &["art-spec-resource".to_string()], true, true, @@ -12347,7 +12447,7 @@ mod canvas_generation_tests { asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, - slice_mode: None, + slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, } @@ -12380,7 +12480,9 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), - spritesheet_slice_mode: Some("grid".to_string()), + spritesheet_slice_mode: Some("connected-components".to_string()), + spritesheet_grid_x: None, + spritesheet_grid_y: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -12704,7 +12806,9 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, - spritesheet_slice_mode: Some("grid".to_string()), + spritesheet_slice_mode: Some("connected-components".to_string()), + spritesheet_grid_x: None, + spritesheet_grid_y: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 6f7aa3441..5ba2980c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -695,6 +695,53 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } + // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。 + if options.asset_kind == "art-spritesheet" { + if options.slice_mode.is_none() { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" + .to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("connected-components") + && (options.grid_x.is_some() || options.grid_y.is_some()) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: + "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明" + .to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("grid") && options.slice_count.is_some() { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount" + .to_string(), + detail: None, + }; + } + } else if options.slice_mode.is_some() + || options.grid_x.is_some() + || options.grid_y.is_some() + || options.slice_count.is_some() + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: format!( + "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}", + options.asset_kind + ), + detail: None, + }; + } if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 45ba5c505..2240b3341 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1036,7 +1036,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" @@ -1311,7 +1311,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { asset_kinds.push(Value::Null); json!({ "type": "object", - "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"], + "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting", "sliceMode", "gridX", "gridY", "sliceCount"], "additionalProperties": false, "properties": { "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, @@ -1320,7 +1320,11 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] }, "assetKind": { "type": ["string", "null"], "enum": asset_kinds }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, - "replaceExisting": { "type": "boolean" } + "replaceExisting": { "type": "boolean" }, + "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, + "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, + "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, + "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" } } }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index f5724810b..739dc5a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4647,7 +4647,10 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, - slice_mode: None, + // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet + // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。 + slice_mode: (asset_kind == "art-spritesheet") + .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 95c7726f6..87608cc7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1198,6 +1198,22 @@ pub(crate) fn prepare_game_creator_project_root_for_read( { WindowsAclRepairScope::UserSelected } else { + #[cfg(all(windows, test))] + if windows_test_temp_path_needs_owner_initialization(path, is_directory) { + // 测试夹具:在提权 shell 里,系统临时目录下新建的目录默认所有者是 + // Administrators 组而不是当前 TokenUser,测试进程无法提权改所有者。 + // 该目录由当前测试进程创建,因此按“本调用创建的对象”初始化所有者后 + // 重试;其它越权所有者、以及临时目录之外的路径仍然失败关闭。 + if windows_path_is_under_test_temp_dir(path) { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; + return Ok(true); + } + } return secure_windows_game_creator_path_for_current_user(path, is_directory, true) .map(|_| true); }; @@ -1720,7 +1736,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read( true, ) } else { - secure_windows_game_creator_path_for_current_user(path, is_directory, true) + verify_game_creator_private_path_or_test_temp_owner(path, is_directory) }; return result.map(|_| true).map_err(|repair_error| { if game_creator_private_path_allows_auto_elevation(path) { @@ -1763,7 +1779,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read( } else { // User-selected external files are never silently adopted. Keep the // strict owner/DACL check, but do not escalate an arbitrary path. - secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + verify_game_creator_private_path_or_test_temp_owner(path, is_directory)?; } Ok(true) } @@ -2114,6 +2130,68 @@ pub(crate) fn validate_game_creator_runtime_config_dir_outside_project( Ok(()) } +/// 测试夹具专用:判断某个已存在的项目根是否只是“系统临时目录下所有者不是当前用户”。 +/// +/// 部分 Windows 主机(例如以提权 shell 运行测试)在 `%TEMP%` 下新建的目录,默认所有者是 +/// `BUILTIN\Administrators` 组而不是当前 TokenUser;测试进程无法提权改所有者,于是严格 +/// 校验会拒绝一个由测试自己创建、且确实位于系统临时目录的目录。只有测试构建、路径位于 +/// 系统临时目录、并且失败原因确实是所有者不匹配时才返回 true;临时目录之外的越权所有者 +/// 继续失败关闭。 +#[cfg(all(windows, test))] +fn windows_test_temp_path_needs_owner_initialization(path: &Path, is_directory: bool) -> bool { + if !path.is_absolute() { + return false; + } + match secure_windows_game_creator_path_for_current_user(path, is_directory, true) { + Ok(()) => false, + Err(error) => { + error.contains("安全对象不属于当前用户") && windows_path_is_under_test_temp_dir(path) + } + } +} + +/// 严格校验一个既有私有对象;测试构建下对系统临时目录内的所有者偏差做一次性所有者 +/// 初始化重试,其余情况保持严格失败关闭。 +#[cfg(windows)] +fn verify_game_creator_private_path_or_test_temp_owner( + path: &Path, + is_directory: bool, +) -> Result<(), String> { + #[cfg(test)] + if windows_test_temp_path_needs_owner_initialization(path, is_directory) { + return secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + ); + } + secure_windows_game_creator_path_for_current_user(path, is_directory, true) +} + +#[cfg(all(windows, test))] +fn windows_path_is_under_test_temp_dir(path: &Path) -> bool { + let normalize = |value: &Path| { + value + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() + }; + let temp_dir = std::env::temp_dir(); + let mut roots = vec![normalize(&temp_dir)]; + if let Ok(canonical) = temp_dir.canonicalize() { + let root = normalize(&canonical); + if !roots.contains(&root) { + roots.push(root); + } + } + let candidate = normalize(path); + roots + .iter() + .any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\"))) +} + #[cfg(windows)] pub(crate) fn secure_windows_game_creator_path_for_current_user( path: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index a9b1ccfff..7aafd8b45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1110,6 +1110,9 @@ struct GeneratedPlatformArtAssetSlice { struct GeneratedPlatformArtAsset { asset: UploadLocalAssetResult, slices: Vec, + slice_mode: Option, + grid_x: Option, + grid_y: Option, resource_id: Option, asset_object_id: Option, task_id: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 7dd5c64a7..89a8d46e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1587,6 +1587,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r "prompt": "生成原创晶体与潮汐构装体图集", "outputPath": "assets/art-spritesheet.png", "assetKind": "art-spritesheet", + "sliceMode": "connected-components", "replaceExisting": true }), ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 2270ca448..5d746c7cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1233,6 +1233,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), + slice_mode: Some("connected-components".to_string()), ..PlatformArtAssetGenerationOptions::default() }, )) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a70761fa3..c120926f0 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3363,7 +3363,7 @@ }, "EditorIconSpritesheetGenerationRequest": { "type": "object", - "required": ["referenceId", "iconDescriptions"], + "required": ["referenceId", "iconDescriptions", "sliceMode"], "properties": { "referenceId": { "type": "string", @@ -3395,26 +3395,25 @@ "connected-components", "grid" ], - "default": "connected-components", - "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。" + "description": "必填,没有默认值:必须在引用解析、定价、入队和任何 provider / OSS 副作用之前显式声明切分模式。需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。connected-components 不接受 gridX/gridY,grid 必须同时提供 gridX/gridY(各 1..32)。省略、null 或空字符串返回 400(field=sliceMode),模式与网格参数互相矛盾返回 400(field=gridX/gridY),两者都不会产生计费、入队或 provider 调用。响应中的 sliceMode 回显本次实际采用的模式。" }, "gridX": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的横向网格数量。" + "description": "grid 模式的横向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "gridY": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的纵向网格数量。" + "description": "grid 模式的纵向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "sliceCount": { "type": "integer", "minimum": 1, - "maximum": 100, - "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。" + "maximum": 256, + "description": "connected-components 模式下可选的目标切片数量(1..256);省略时按图像内容自动识别上限。识别结果与该目标数量不一致、为 0 或超过 256 时返回 422 并给出实际识别数量,不会静默截断。grid 模式的切片数量由 gridX×gridY 决定,不接受该字段。" }, "screenColor": { "type": ["string", "null"], @@ -3649,7 +3648,7 @@ "connected-components", "grid" ], - "description": "实际采用的图集切分模式。" + "description": "本次实际采用的图集切分模式,与请求显式声明的 sliceMode 一致;图集生成入口不回退到任何默认模式。" }, "gridX": { "type": "integer", @@ -3664,7 +3663,7 @@ "sliceCount": { "type": "integer", "minimum": 0, - "maximum": 100, + "maximum": 256, "description": "实际生成的切片数量。" }, "sliceWarning": { diff --git a/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md new file mode 100644 index 000000000..d365f6539 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md @@ -0,0 +1,39 @@ +# 【实施计划】图集切片模式显式决策 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`server-rs/crates/api-server`(图标图集生成入口、错误体、画板 Agent 工具装配、OpenAPI 契约测试)、平台画板前端(`src/services/image-editor`、`src/components/image-editor`)、AGC 客户端(`apps/ai-game-creator-shell/src-tauri` 的 MCP 工具说明、桥接校验、原生工具 schema、图集生成选项与调用方、AGC Skill)、`.codex/skills/genarrative-external-editor-api`、`docs/openapi/genarrative-external-v1.openapi.json`、主规范与共享记忆。 +- 明确不修改 `platform-editor-agent`:画板 Agent 的工具参数不变,其链路在装配层固定显式声明 `connected-components`,画板因此不具备网格生成入口。 +- 明确不修改:拆分 / 去背 / 像素规整算法、切片上限、手动拆分入口行为、SpacetimeDB schema、旧版本客户端兼容分支。 + +## 实现顺序 + +1. 平台入口:`sliceMode` 由可选改必填并校验模式自洽性,失败发生在引用解析、定价、入队之前。 +2. 公开契约:OpenAPI 请求体去掉默认值、补必填与失败语义,并补契约测试。 +3. 平台自有调用方显式声明模式:画板 Agent 工具装配(固定连通域)、画板前端提交计划(固定连通域)。 +4. AGC 客户端:MCP 工具说明与桥接校验、原生工具 schema 与观察器、图集生成选项与全部调用方、AGC Skill 与外部 MCP 说明。 +5. 错误可执行性:切片模式按原始字符串接收后逐项校验,统一返回 `field`、允许取值与决策分支;`sliceCount` 契约上限与切片上限对齐。 +6. 反馈闭环:生成结果回显生效声明与切片路径,严格图集在本地提交前校验回显与请求一致。 +7. 标准美术包显式声明 `connected-components` + `sliceCount=4`,用途映射前校验切片数量正好为四。 +8. 测试环境:为提权 Windows 主机上的 `%TEMP%` 所有者偏差补测试构建专用的所有者初始化重试(仅限临时目录内、且失败原因为所有者不匹配)。 +9. 文档与共享记忆同步,最后运行定向验证与编码 / diff 检查。 + +## 验证命令 + +1. `cargo test -p api-server editor_icon_spritesheet`(名称按实际测试筛选) +2. `cargo test -p platform-editor-agent` +3. `npm run test -- src/services/image-editor/editorProjectClient.test.ts`(按仓库既有前端测试入口) +4. `cargo test -p ai-game-creator-shell` 定向筛选 `slice_mode` / `generate_image` +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 风险 1:已发布的 AGC 客户端与第三方外部 API 调用方在未更新前会因缺失 `sliceMode` 收到 `400`。回滚点为「恢复服务端兜底读取连通域」,但该兜底与本次里程碑目标冲突,需产品确认后再引入过渡期。 +- 风险 2:AGC 原生工具 schema 从“可选”改为“显式声明”,自主运行时可能出现一轮可修复的工具参数失败。回滚点为「保留 schema 字段但收回 description 中的强制措辞」。 +- 风险 3:画板前端显式声明模式后,画板自身不再具备网格生成能力;需要网格时改用外部 API 或后续单独开放画板入口。 diff --git a/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md new file mode 100644 index 000000000..49925b314 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md @@ -0,0 +1,49 @@ +# 【里程碑】图集切片模式显式决策 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-17 | +| Parent Spec | `docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md` | + +## 目标 + +图标图集生成的切分模式不再具备任何隐式默认:平台入口、AGC 客户端自有流程、画板前端和所有 Agent / 工具说明都必须在请求中显式声明 `sliceMode`,并在同一份决策要求下选择 `connected-components` 或 `grid`。 + +## 范围 + +- `sliceMode` 在图标图集生成入口成为必填;缺失、`null`、空字符串在副作用之前失败关闭。 +- `grid` 与 `connected-components` 的参数自洽性:`grid` 必须带行列数,连通域不得携带网格尺寸。 +- 决策要求写入主规范、公开契约、MCP / Agent 工具说明、Skill 与客户端自有路径,口径一致。 +- 依赖平台默认值的自有调用方全部改为显式声明,且不新增兜底分支。 +- 失败信息可执行:所有拒绝路径都带字段名与决策要求,`sliceCount` 的目标数量与上限语义在契约中写清。 +- 端到端可证明:生成结果回显生效的切分声明与切片路径,严格图集在本地提交前校验回显与请求一致。 +- 标准美术包显式声明四张 canonical 切片的切分声明,并在用途映射前校验切片数量正好为四。 + +## 不在范围内 + +- 不改动图集生成、去背、像素规整、拆分算法本身和切片上限。 +- 不新增切分模式,不恢复已退役的固定网格契约。 +- 不改动手动 `拆分图集` 入口的既有行为。 +- 不为旧版本客户端保留过渡性兜底。 + +## 依赖与前置条件 + +- 无外部依赖;`sliceMode`、`gridX`、`gridY` 契约字段已在现行版本存在。 + +## 验收标准 + +- [ ] 省略 / `null` / 空字符串 `sliceMode` 的图集生成请求在定价、入队、扣费和 provider 调用之前返回 `400`,错误体含 `field=sliceMode`。 +- [ ] `grid` 缺 `gridX` 或 `gridY`、越界、乘积超限时 `400`;`connected-components` 携带 `gridX`/`gridY` 时 `400`。 +- [ ] 公开契约、MCP / Agent 工具说明、Skill 与画板前端类型都要求显式声明,且不再声明任何默认值。 +- [ ] AGC 客户端与画板前端的所有图集生成路径都显式传入模式,不再依赖平台兜底。 +- [ ] 响应回显的 `sliceMode` 与请求声明一致;`grid` 时同时回显行列数。 +- [ ] 拒绝信息包含字段名、允许取值与决策分支;`sliceCount` 契约上限与切片上限一致。 +- [ ] 标准美术包声明 `sliceCount=4`,数量不符时在写入用途清单前失败关闭。 + +## 证据要求 + +- 自动化:平台定向测试(缺失、空串、连通域带网格尺寸、grid 缺维度、正常两种模式)、OpenAPI 契约测试、前端与 AGC 客户端定向测试。 +- 运行时:本地 `api-server` smoke 提交一次缺字段请求,确认返回 `400` 且无扣费 / 入队记录。 +- 边界:确认失败发生在引用解析、定价、入队与 OSS 副作用之前;确认响应字段与请求一致。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01152bcba..ddb8c3926 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,7 +2,17 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-17 图集切分模式改为显式声明 +- 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 +- 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。 +- 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。 +- 迁移影响:省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端和第三方外部 API 调用方)会在图集生成上收到 `400`;本次同时把仓库内自有调用方改为显式声明,不为旧客户端保留兜底分支。 +- 错误可执行性:缺失、空白、未知取值都以 `400` + `field=sliceMode` 返回允许取值和决策分支,`grid` 缺维度提示 `sliceCount` 才是张数约束;`sliceCount` 的公开契约上限与切片上限统一为 `256`(识别数量与目标不一致返回 `422` 并回报实际数量)。 +- 反馈闭环:图集生成结果回显生效的 `sliceMode`/`gridX`/`gridY` 与 `slicePaths`;严格图集提交前必须证明平台回显的模式(`grid` 时含行列数)与请求显式声明一致,缺失或不一致一律失败关闭。 +- 标准美术包:客户端显式声明 `sliceMode=connected-components` + `sliceCount=4`,本地按用途位置写四张 canonical 切片前再次校验数量正好为四,数量不符时失败关闭,禁止截断或补位。 +- 测试环境:在提权 shell 的 Windows 主机上,`%TEMP%` 下新建目录的默认所有者是 `BUILTIN\Administrators` 而不是当前 TokenUser,AGC 的所有者校验会拒绝测试自己创建的项目根;测试构建对该情形(仅限 `%TEMP%` 内、且失败原因为所有者不匹配)先按“本调用创建的对象”初始化所有者后重试,临时目录之外的越权所有者继续失败关闭。 +- 权威合同:[画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 ## 2026-09-17 `agc_tools` 媒体资源提示词上限收敛为单一口径,并按 kind 暴露给模型 - 背景:有人反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。核查后工具本身都在(`agc_edit_image` / `agc_create_or_derive_resource`),图片快速编辑在 2026-09-14 的真实项目日志里也有成功记录;但存在三类真实缺陷:① `agc_create_or_derive_resource` 的 `prompt` 在 schema 里只声明 4000,真实上限却是按 kind 分的(背景音乐 140、音效 1900、视频/角色动画 4000、图片 32000),MCP 层还额外写死了一条 140 判断,模型从 schema 与 skill 都看不出 140/1900,写一句正常长度的背景音乐描述就当场被拒;② 客户端 UI 用同一口径但会截断并提示,agent 侧却只有硬拒,形成「UI 能做、agent 调不动」的观感;③ `sourceLocalAssetId` 不是已登记资源时只报「不属于当前项目已登记资源」,模型会原地重试而不会先登记。 @@ -12,7 +22,6 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 - 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 - ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index fec3db0e7..b5c244b6e 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -27,6 +27,7 @@ - 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。 - 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。 - 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。 +- 画布 Agent 的 `generate-icon-spritesheet` 不暴露切分模式参数,链路固定显式传 `sliceMode=connected-components`;等分网格或固定槽位需求必须由外部 API 调用方显式传 `sliceMode=grid` 与来自需求的 `gridX`/`gridY`,画板工具栏的 `拆分图集` 仍只做连通域拆分。禁止在工具描述、确认卡或回复里承诺按 `2×2` 等网格切分。 - 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。 - function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K`。`generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution` 为 `480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。 - `generate-sound-effect` 与站内 / External v1 的 SFX V2 契约一致:Prompt 使用 ECMAScript `String.trim()` 等值 canonicalization 且限制 `1–2048` Unicode code points,model 固定 `eleven_text_to_sound_v2`,`duration` 缺省为手动 `5s`、显式 `null` 为自动时长、数值范围为有限 `0.5–30` 小数,`loop` 缺省 false。显式 `duration:null` 必须绕过通用“顶层 null 当缺省”兼容层,不能在 job payload 中变回 `5s`;确认后的 canonical payload 继续进入现有 `editor_sound_effect_generation` Worker,不新增 Agent 专属音频链路。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index ae769d8b3..b362711d4 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-08-10` +更新时间:`2026-09-17` ## 背景 @@ -40,7 +40,15 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 -- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。 +- 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。 +- 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。 +- `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。 +- `connected-components` 不得同时携带 `gridX` / `gridY`:连通域切分不接受网格尺寸,二者同时出现时按请求自相矛盾在副作用之前返回 `400`(`field=gridX/gridY`),避免调用方以为网格已生效而实际按连通域执行。 +- 决策要求(服务端、客户端、Agent、工具说明和 Skill 必须一致):只有在用户或需求明确要求等分网格、固定槽位或指定行列数时,才使用 `grid`,并把该行列数作为 `gridX` / `gridY` 传入;行列数必须来自用户或需求本身,不得由生成方自行假定,也不得用固定 `2×2` 表达“四类素材”。自由排布、数量不定或只要求“一张图集”时,显式传 `connected-components`;需要约束素材张数时使用 `sliceCount`,不得用网格参数表达张数。调用方、客户端和 Agent 都不得依赖、补齐或推断省略值。 +- 响应继续回显实际采用的 `sliceMode`,`grid` 时同时回显生效的 `gridX` / `gridY`。 +- 错误必须可执行:缺失、空白和未知取值统一返回 `400` 且带 `field=sliceMode`,`grid` 与网格参数的矛盾带 `field=gridX/gridY`,message 说明允许取值、缺参时该走哪条决策分支,以及 `sliceCount` 才是张数约束;不得只回报通用 JSON 解析错误。 +- `sliceCount` 只约束 `connected-components` 的目标张数,取值 `1..256`;识别结果与该目标不一致、为 `0` 或超过上限时返回 `422` 并回报实际识别数量,`grid` 不接受该字段。 +- 客户端的标准美术包(四类 canonical 素材)必须显式声明 `sliceMode=connected-components` 与 `sliceCount=4`:平台要么给出四张切片,要么以可执行的 `422` 说明实际识别数量;本地按用途位置映射前必须再次校验切片数量正好是四张,数量不符时失败关闭,禁止靠截断或补位写出用途错位的切片清单。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 @@ -89,7 +97,7 @@ - 透明背景处理正常成功时,父流程把带背景原图和经完整解码 / 尺寸守卫验证的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别 alpha 连通域并执行附加拆分。BgFilter 最终失败或后续 Alpha / 尺寸恢复、原图回读、透明图完整解码失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`、`sliceWarning=null`。该收口不捕获 phase 上报、provider 原图持久化或 `canvasCompletion` 写回错误;provider 原图本身解码失败时在首次持久化前失败,不允许用 `512×512` 伪造元数据。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材。图标自动拆分、手动 `拆分图集` 和 UI 提取复用同一个 bounded CPU helper 和 platform 实现:全部原始连通域(包括随后过滤的噪点)最多 `4096` 个,辅助部件通过 `64px` 空间网格只检查最大 `48px` 邻域候选;有效输出按视觉阅读顺序命名为 `素材 N`。 -- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `64` 个输出;输出限制在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。 +- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `256` 个输出;输出上限与 `grid` 的 `gridX × gridY` 上限、`sliceCount` 上限取同一个值,并在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。 ## 前端铺放规则 @@ -110,3 +118,4 @@ - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 - 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。 - 生成图标素材的提交体不包含 `priceMudPoints`;后端必须按归一化后的模型和尺寸计算价格,不信任客户端声明。queue 任务的计费、退款和结果投影使用入队时冻结的同一价格。 +- 图集生成请求省略 `sliceMode`(或显式传 `null` / 空字符串)时返回 `400` 且 `field=sliceMode`,不产生定价、入队、扣费、provider 调用或 OSS 写入;`sliceMode=connected-components` 同时携带 `gridX`/`gridY` 时同样在副作用之前 `400`;`grid` 缺任一维度时 `400`。响应回显的 `sliceMode` 必须与请求声明一致。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 0805fb4d4..53d051abc 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, slice_count: None, - slice_mode: None, + // 画板 Agent 只生成自由排布的图标表,因此显式声明连通域切分;等分网格或固定 + // 槽位需求必须由调用方在外部 API 显式传 grid + gridX/gridY,不能依赖任何默认值。 + slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, style: None, diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index d0e68e432..c109dab63 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -76,13 +76,13 @@ pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000; pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; -const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; +pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; -const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; +pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = @@ -255,9 +255,11 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, - /// 图集切分模式;省略时使用连通域切分。 + /// 图集切分模式;必填且没有默认值,必须在任何副作用之前由调用方显式声明。 + /// 这里按原始字符串接收,让业务校验能返回带 `field` 和决策要求的 400, + /// 而不是只让 serde 抛一个通用的 JSON 解析错误。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) slice_mode: Option, + pub(crate) slice_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) grid_x: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -283,16 +285,57 @@ pub(crate) enum EditorIconSpritesheetSliceMode { Grid, } -impl Default for EditorIconSpritesheetSliceMode { - fn default() -> Self { - Self::ConnectedComponents +/// `sliceMode` 的显式决策要求:该字段没有默认值,缺失即拒绝。 +pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE: &str = "切分模式没有默认值,必须显式声明:需求明确要求等分网格、固定槽位或指定行列数时传 sliceMode=grid,并用 gridX/gridY 传入该行列数;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components,需要约束素材张数时使用 sliceCount。"; + +fn editor_icon_spritesheet_slice_mode_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "sliceMode", + "message": message, + })) +} + +/// 解析显式的切分模式声明:缺失、空白和未知取值都返回可执行的 400。 +fn parse_editor_icon_spritesheet_slice_mode( + slice_mode: Option<&str>, +) -> Result { + let Some(value) = slice_mode.map(str::trim) else { + return Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不能省略:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))); + }; + match value { + "" => Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不能为空字符串:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))), + "connected-components" => Ok(EditorIconSpritesheetSliceMode::ConnectedComponents), + "grid" => Ok(EditorIconSpritesheetSliceMode::Grid), + other => Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不支持 {other},只允许 connected-components 或 grid:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))), } } -fn resolve_editor_icon_spritesheet_slice_mode( - slice_mode: Option, -) -> EditorIconSpritesheetSliceMode { - slice_mode.unwrap_or_default() +/// 解析并校验图集切分声明:缺失模式、模式与网格参数互相矛盾都在此失败关闭。 +fn resolve_editor_icon_spritesheet_slice_request( + slice_mode: Option<&str>, + grid_x: Option, + grid_y: Option, +) -> Result<(EditorIconSpritesheetSliceMode, u32, u32), AppError> { + let slice_mode = parse_editor_icon_spritesheet_slice_mode(slice_mode)?; + if slice_mode == EditorIconSpritesheetSliceMode::ConnectedComponents + && (grid_x.is_some() || grid_y.is_some()) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 sliceMode=grid 同时声明。", + })), + ); + } + let (grid_x, grid_y) = + resolve_editor_icon_spritesheet_grid_dimensions(slice_mode, grid_x, grid_y)?; + Ok((slice_mode, grid_x, grid_y)) } fn resolve_editor_icon_spritesheet_grid_dimensions( @@ -307,7 +350,10 @@ fn resolve_editor_icon_spritesheet_grid_dimensions( return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", - "message": "grid 模式必须同时提供 gridX 与 gridY。", + "message": format!( + "sliceMode=grid 必须同时提供 gridX 与 gridY(各 1 到 {}):行列数必须来自需求本身;用网格参数表达素材张数时应改用 sliceMode=connected-components 加 sliceCount。", + EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS + ), })), ); }; @@ -1464,6 +1510,12 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( mut payload: EditorIconSpritesheetGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { + // 切分模式没有默认值:必须在引用解析、定价和入队之前显式声明。 + resolve_editor_icon_spritesheet_slice_request( + payload.slice_mode.as_deref(), + payload.grid_x, + payload.grid_y, + )?; payload.generation_inputs = sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); payload.icon_descriptions = @@ -1545,6 +1597,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller: EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { + // 切分模式没有默认值:必须在引用解析、定价和任何 provider / OSS 副作用之前显式声明。 + let (requested_slice_mode, grid_x, grid_y) = resolve_editor_icon_spritesheet_slice_request( + payload.slice_mode.as_deref(), + payload.grid_x, + payload.grid_y, + )?; payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( @@ -1647,12 +1705,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; - let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode); - let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions( - requested_slice_mode, - payload.grid_x, - payload.grid_y, - )?; // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, @@ -3058,19 +3110,97 @@ mod tests { } #[test] - fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() { + fn slice_mode_must_be_declared_and_accepts_explicit_modes() { + let missing = resolve_editor_icon_spritesheet_slice_request(None, None, None) + .expect_err("omitted sliceMode must fail closed"); + assert_eq!(missing.status_code(), StatusCode::BAD_REQUEST); assert_eq!( - resolve_editor_icon_spritesheet_slice_mode(None), - EditorIconSpritesheetSliceMode::ConnectedComponents + missing.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + missing + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("没有默认值") + && message.contains("grid") + && message.contains("connected-components")), + "{:?}", + missing.details() + ); + let empty = resolve_editor_icon_spritesheet_slice_request(Some(" "), None, None) + .expect_err("blank sliceMode must fail closed"); + assert_eq!( + empty.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + empty + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("不能为空字符串") + && message.contains("connected-components")), + "{:?}", + empty.details() + ); + let unknown = + resolve_editor_icon_spritesheet_slice_request(Some("grid-2x2"), Some(2), Some(2)) + .expect_err("unknown sliceMode must fail closed with its own message"); + assert_eq!( + unknown.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + unknown + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and( + |message| message.contains("grid-2x2") && message.contains("没有默认值") + ), + "{:?}", + unknown.details() ); assert_eq!( - resolve_editor_icon_spritesheet_grid_dimensions( - EditorIconSpritesheetSliceMode::Grid, - Some(3), - Some(2), + resolve_editor_icon_spritesheet_slice_request( + Some("connected-components"), + None, + None, ) - .expect("grid dimensions should validate"), - (3, 2) + .expect("explicit connected-components mode should validate"), + (EditorIconSpritesheetSliceMode::ConnectedComponents, 0, 0) + ); + assert_eq!( + resolve_editor_icon_spritesheet_slice_request(Some("grid"), Some(3), Some(2),) + .expect("grid dimensions should validate"), + (EditorIconSpritesheetSliceMode::Grid, 3, 2) + ); + let contradictory = resolve_editor_icon_spritesheet_slice_request( + Some("connected-components"), + Some(2), + Some(2), + ) + .expect_err("grid dimensions must not accompany connected-components"); + assert_eq!(contradictory.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + contradictory + .details() + .and_then(|details| details.get("field")), + Some(&json!("gridX/gridY")) + ); + let grid_without_dimensions = + resolve_editor_icon_spritesheet_slice_request(Some("grid"), None, None) + .expect_err("grid without dimensions must fail closed"); + assert!( + grid_without_dimensions + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("sliceCount")), + "{:?}", + grid_without_dimensions.details() ); let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", @@ -3079,8 +3209,22 @@ mod tests { })) .expect("explicit connected-components mode should deserialize"); assert_eq!( - connected.slice_mode, - Some(EditorIconSpritesheetSliceMode::ConnectedComponents) + connected.slice_mode.as_deref(), + Some("connected-components") + ); + let omitted: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"] + })) + .expect("omitted sliceMode stays deserializable so the route can return its own 400"); + assert_eq!(omitted.slice_mode, None); + assert!( + resolve_editor_icon_spritesheet_slice_request( + omitted.slice_mode.as_deref(), + omitted.grid_x, + omitted.grid_y, + ) + .is_err() ); let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", @@ -3090,6 +3234,7 @@ mod tests { "gridY": 2 })) .expect("grid mode should deserialize"); + assert_eq!(grid.slice_mode.as_deref(), Some("grid")); assert_eq!(grid.grid_x, Some(3)); assert_eq!(grid.grid_y, Some(2)); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 99923530f..6a53d32d8 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2644,13 +2644,37 @@ mod tests { icon_spritesheet_request["properties"]["sliceCount"]["minimum"], json!(1) ); + assert_eq!( + icon_spritesheet_request["properties"]["sliceCount"]["maximum"], + json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_SLICES) + ); assert_eq!( icon_spritesheet_request["properties"]["sliceMode"]["enum"], json!(["connected-components", "grid"]) ); + assert!( + icon_spritesheet_request["properties"]["sliceMode"] + .get("default") + .is_none(), + "sliceMode must not advertise a default" + ); + assert!( + icon_spritesheet_request["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("sliceMode"))), + "sliceMode must be required" + ); + assert!( + icon_spritesheet_request["properties"]["sliceMode"]["description"] + .as_str() + .is_some_and(|description| description.contains("没有默认值") + && description.contains("field=sliceMode") + && description.contains("gridX/gridY")), + "sliceMode description must carry the explicit decision requirement" + ); assert_eq!( icon_spritesheet_request["properties"]["gridX"]["maximum"], - json!(32) + json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 6b6c469f7..2042fa3d6 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str = "genarrative://external-editor/skill/references/requests-and-outputs.md"; const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; -const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; +const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; #[derive(Clone, Debug)] struct McpOperation { diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 004c9d5bd..cdbef119d 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2749,6 +2749,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect.objectContaining({ referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], + sliceMode: 'connected-components', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 7a7ec1dd8..d6e9d77f9 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1080,6 +1080,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { referenceId: 'resource-icon-spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 6c14a925d..b11548253 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -1136,6 +1136,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan( } : {}), iconDescriptions, + // 切分模式没有默认值:画板链路按自由排布生成图标表,因此显式声明连通域切分; + // 等分网格或固定槽位需求必须由调用方显式传 grid + gridX/gridY。 + sliceMode: 'connected-components', model: rememberImageModel, screenColor, segModel, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b481fdb2c..e4e4a1159 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -4249,6 +4249,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { 'generated-character-drafts/editor/refs/icon-style.png', ], iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', }), ); }); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index d846e24e3..79e965d9f 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1112,6 +1112,7 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: references.slice(0, 5), iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', }), ).rejects.toThrow('图标素材参考图最多允许 4 张'); @@ -1162,6 +1163,29 @@ describe('editorProjectClient', () => { ); }); + it('requires an explicit slice declaration and rejects contradictory grid dimensions', async () => { + requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } }); + + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'grid', + }), + ).rejects.toThrow('sliceMode=grid 必须同时提供 gridX 与 gridY'); + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', + gridX: 2, + gridY: 2, + }), + ).rejects.toThrow('sliceMode=connected-components 不接受 gridX/gridY'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('rejects oversized stable video reference fields before submission', async () => { await expect( generateEditorVideo({ @@ -1331,6 +1355,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, assetLabel: '冒险游戏图标', }); @@ -1348,6 +1375,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', }), @@ -1365,6 +1395,7 @@ describe('editorProjectClient', () => { generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)], + sliceMode: 'connected-components', }), ).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`); @@ -1377,6 +1408,7 @@ describe('editorProjectClient', () => { '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), ], + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, @@ -1388,6 +1420,7 @@ describe('editorProjectClient', () => { iconDescriptions: Array.from({ length: 8 }, () => '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, @@ -1412,6 +1445,7 @@ describe('editorProjectClient', () => { await generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', @@ -1434,6 +1468,7 @@ describe('editorProjectClient', () => { body: JSON.stringify({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e84825190..55003d328 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -380,7 +380,8 @@ export type EditorIconSpritesheetGenerationInput = { referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; - sliceMode?: 'connected-components' | 'grid'; + /** 必填且没有默认值:必须显式声明连通域或网格切分。 */ + sliceMode: 'connected-components' | 'grid'; gridX?: number; gridY?: number; model?: string; @@ -1300,6 +1301,16 @@ export async function generateEditorIconSpritesheet( input.iconDescriptions, ); const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; + // 切分模式没有默认值:声明必须自洽,网格尺寸只能与 grid 同时提交。 + if (input.sliceMode === 'grid') { + if (input.gridX === undefined || input.gridY === undefined) { + throw new Error('sliceMode=grid 必须同时提供 gridX 与 gridY'); + } + } else if (input.gridX !== undefined || input.gridY !== undefined) { + throw new Error( + 'sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时提交', + ); + } assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); assertEditorReferenceLimit( input.referenceImageSrcs, @@ -1317,9 +1328,13 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions, - ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}), - ...(input.gridX !== undefined ? { gridX: input.gridX } : {}), - ...(input.gridY !== undefined ? { gridY: input.gridY } : {}), + sliceMode: input.sliceMode, + ...(input.sliceMode === 'grid' && input.gridX !== undefined + ? { gridX: input.gridX } + : {}), + ...(input.sliceMode === 'grid' && input.gridY !== undefined + ? { gridY: input.gridY } + : {}), model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), From 504e26da4320af473c4dd1bd6f8103dac121b58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Thu, 17 Sep 2026 18:11:54 +0800 Subject: [PATCH 18/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92agent?= =?UTF-8?q?=20panic=E6=AD=BB=E6=8E=89=E9=97=AE=E9=A2=98=20(#405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/405 --- .../src-tauri/src/agent/design_runtime.rs | 181 +++++++++++++++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 1a5a6e486..9e907c2c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1,5 +1,6 @@ use super::design_tools::*; use super::*; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; @@ -11,6 +12,46 @@ use uuid::Uuid; const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; +const DESIGN_PANIC_PUBLIC_ERROR: &str = + "策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。"; + +// 运行段经 task-local 携带项目根,panic hook 据此把位置和负载写进私有 design_debug。 +// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 +tokio::task_local! { + static DESIGN_PANIC_ROOT: Option; +} + +fn ensure_design_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) { + let location = info + .location() + .map(|location| { + format!( + "{}:{}:{}", + location.file(), + location.line(), + location.column() + ) + }) + .unwrap_or_else(|| "未知位置".to_string()); + design_debug( + &root, + "panic", + json!({ + "location": location, + "error": info.payload_as_str().unwrap_or("未知 panic 负载"), + }), + ); + } + previous(info); + })); + }); +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde( tag = "type", @@ -421,6 +462,10 @@ fn execute_design_tool( ) -> Result { let args: Value = serde_json::from_str(&call.arguments) .map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; + #[cfg(test)] + if call.name == "design_test__panic" { + panic!("注入的策划工具 panic"); + } match call.name.as_str() { "get_workflow_status" => Ok(design_workflow_status(session)), "list_resources" => resources.list().map(Value::String), @@ -971,7 +1016,20 @@ async fn finish_design_command( Some(design_view(&session, run)), )); if run { - if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await { + // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、 + // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不 + // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。 + ensure_design_panic_hook(); + let run = DESIGN_PANIC_ROOT.scope( + Some(root.to_path_buf()), + run_design_loop(root, resources, &mut session, &mut emit), + ); + let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await; + let result = match outcome { + Ok(result) => result, + Err(payload) => Err(design_panic_error(payload)), + }; + if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); @@ -991,6 +1049,12 @@ async fn finish_design_command( Ok(view) } +// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 +// design_debug(task-local 提供项目根),不进入用户可见消息。 +fn design_panic_error(_payload: Box) -> String { + DESIGN_PANIC_PUBLIC_ERROR.to_string() +} + pub(crate) async fn continue_design_agent_at( root: &Path, resources: &DesignResources, @@ -1632,6 +1696,121 @@ mod tests { .clone() } + // 进程级 env 在同一 binary 的并行用例间共享:持锁串行化修改,drop 时恢复原值, + // 避免 debug 开关泄漏给并发用例。锁中毒时取内部值继续,不让上游失败放大。 + static DESIGN_DEBUG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct DesignDebugEnvGuard { + previous: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl Drop for DesignDebugEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", value), + None => std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"), + } + } + } + + fn enable_design_debug_for_test() -> DesignDebugEnvGuard { + let lock = DESIGN_DEBUG_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG").ok(); + std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1"); + DesignDebugEnvGuard { + previous, + _lock: lock, + } + } + + #[tokio::test(flavor = "current_thread")] + async fn design_tool_panic_becomes_visible_retryable_error() { + let (_temp, root, resources) = init_design_project(); + let _debug_env = enable_design_debug_for_test(); + let panic_call = platform_llm::LlmToolCall { + id: "call-panic".into(), + name: "design_test__panic".into(), + arguments: "{}".into(), + }; + let _fake = fake_provider::install( + vec![ + Ok(fake_response("panic-turn", "", vec![panic_call])), + Ok(fake_response("recovery", "已恢复", Vec::new())), + ], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "turn-panic", + DesignInput::Message { + text: "需求".into(), + }, + |_| {}, + ) + .await + .expect("panic 必须转成可恢复视图而不是向上传播"); + assert!(!view.running); + assert!(view.can_retry); + assert_eq!( + view.session.last_error.as_deref(), + Some(DESIGN_PANIC_PUBLIC_ERROR) + ); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!( + !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + }), + "panic 不得写半个工具输出" + ); + + // design_debug 经独立线程落盘,轮询等待 panic 记录出现。 + let debug_dir = root.join(".debug/design-agent"); + let mut panic_record = None; + for _ in 0..100 { + panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| { + entries.flatten().find_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_string_lossy().into_owned(); + if name.ends_with("-panic.json") { + fs::read_to_string(path).ok() + } else { + None + } + }) + }); + if panic_record.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug"); + assert!(panic_record.contains("design_runtime.rs")); + assert!(panic_record.contains("注入的策划工具 panic")); + + let view = + continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {}) + .await + .expect("panic 后可重试"); + assert!(!view.running); + assert!(!view.can_retry); + assert!(view.session.last_error.is_none()); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending)); + assert!(session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + })); + } + #[test] fn design_request_enables_reasoning_capture_only_for_design_runtime() { let session = new_design_session("project", "quality"); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..0828b8fda 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,6 +4,12 @@ `patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 +## 策划 Agent 运行 panic 边界 + +`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。 + +运行段通过 task-local 携带项目根;首次运行时安装的 panic hook 在 panic 瞬间把代码位置(file:line:column)和负载追加写入私有 design_debug,随后交给原 hook 维持既有 stderr 输出。hook 只在策划运行段内生效(其它任务无 task-local 上下文时直接透传),多线程 runtime 下 future 跨 worker 迁移也能正确归因。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 From ce04d8cef4eceb123fb133e49878a4d59168895a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:10:41 +0800 Subject: [PATCH 19/68] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=BA=BF=E5=A2=9E=E5=8A=A0=20OSS=20=E6=BC=94=E7=BB=83?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jenkins 增加布尔参数 AGC_RELEASE_DRY_RUN,勾选后只构建并打印上传命令,不写入 OSS - 参数转换为脚本读取的 AGC_RELEASE_DRY_RUN 环境变量,完成提示区分演练与真发 --- jenkins/Jenkinsfile.ai-game-creator-shell-build | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 5bd728af0..8ac2484e2 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -23,6 +23,7 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch') choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行') + booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS') text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -132,6 +133,7 @@ pipeline { "OSSUTIL_BIN=${params.OSSUTIL_BIN}", "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", + "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' @@ -164,7 +166,9 @@ pipeline { post { success { - echo "AGC ${params.AGC_UPDATE_CHANNEL} 渠道安装包、签名与渠道清单已构建并上传 OSS。" + echo params.AGC_RELEASE_DRY_RUN + ? "AGC ${params.AGC_UPDATE_CHANNEL} 渠道演练完成:已构建并生成清单,未写入 OSS。" + : "AGC ${params.AGC_UPDATE_CHANNEL} 渠道安装包、签名与渠道清单已构建并上传 OSS。" } } } From 9dd105237406597d5e8fc77cc7bbf77a23aa45ea Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:18:32 +0800 Subject: [PATCH 20/68] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=A0=BC=E5=BC=8F=E4=B8=8E=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=89=8D=E6=95=B4=E7=BB=84=E6=A0=A1=E9=AA=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排除不可解码SVG参考并约束上传权限与账号归属判据 --- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index fb67c02db..70b355746 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -11,6 +11,7 @@ - 本合同不包含拖入对话批量 @、复制聊天引用、历史替换交互、SpacetimeDB schema 或新的远程公开 API。共享表现与交互优先扩展公共组件,正式资源状态仍由宿主/原生链路维护。 - 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 +- 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 - 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。 From 30648e6b938a3dced4ab617d1388bb5bcd99e258 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:11:06 +0800 Subject: [PATCH 21/68] =?UTF-8?q?=E7=94=BB=E5=B8=83=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=9A=E9=87=8D=E7=AE=97=E6=84=8F=E5=9B=BE=E5=90=84=E5=8D=A0?= =?UTF-8?q?=E9=98=9F=E5=88=97=E6=A7=BD=E3=80=81=E6=92=A4=E9=94=80=E4=B8=8D?= =?UTF-8?q?=E8=B7=A8=E6=8E=92=E5=BA=8F=E6=A8=A1=E5=BC=8F=E3=80=81=E6=95=B4?= =?UTF-8?q?=E7=90=86=E5=88=A4=E6=8D=AE=E4=B8=8E=E8=90=BD=E7=9B=98=E5=90=8C?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理 - 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名 - 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去 - 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化 - 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY - 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘 --- .../src/view/project-development/index.tsx | 59 +++- .../project-development/resourceBookLayout.ts | 39 +-- .../useProjectResourceCanvasLayout.ts | 67 +++-- .../tests/resourceBookLayout.test.ts | 61 +--- .../tests/resourceCanvasManualLayout.test.tsx | 85 ++++++ .../useProjectResourceCanvasLayout.test.ts | 263 ++++++++++++++++++ 6 files changed, 455 insertions(+), 119 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 27b8867ad..80c67a98f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -179,6 +179,7 @@ import { pushResourceCanvasHistory, redoResourceCanvasHistory, resolveResourceCanvasRestoreEntries, + type ResourceCanvasHistory, type ResourceCanvasLayoutSnapshot, undoResourceCanvasHistory, } from '../../features/resource-canvas/resourceCanvasHistoryModel'; @@ -419,6 +420,14 @@ export function resolveResourceCardDragMoves({ return moves.length > 0 ? moves : [moveFor(resource)]; } +/** + * 撤销栈作用域不匹配时对外呈现的空栈。 + * + * 单独放一个共享常量,而不是每次渲染新建:`canUndo / canRedo` 与撤销回调都按历史对象做依赖, + * 每帧换一个空对象会让它们无意义地反复重建。 + */ +const EMPTY_RESOURCE_CANVAS_HISTORY = createResourceCanvasHistory(); + function isPlatformAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); return ( @@ -1367,8 +1376,6 @@ function ResourceBookScene({ width: number; height: number; rotation: number; - dragX: number; - dragY: number; }, ) => ReactNode; /** 渲染在画本世界坐标系里的覆盖层(框选矩形等)。 */ @@ -1678,9 +1685,49 @@ export default function ProjectDevelopmentView({ const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false); const [resourceCanvasMarquee, setResourceCanvasMarquee] = useState(null); - /** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */ - const [resourceCanvasHistory, setResourceCanvasHistory] = useState( - createResourceCanvasHistory, + /** + * 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 + * + * 撤销栈按「项目 + 排序模式」隔离:两种排序各有一份独立的布局 sidecar,同一份历史套用到 + * 另一份 sidecar 上就是把坐标写进别人的文件。作用域记在状态里、读的时候先比对——切模式后 + * 当前历史按空栈看待,但那一段历史仍然留在状态里(切回来还能继续撤销),也不会被当成 + * 当前作用域的历史被撤销消费掉。 + */ + const resourceCanvasHistoryScopeKey = JSON.stringify([ + projectPath, + manifest.projectId, + sortMode, + ]); + const [resourceCanvasHistoryState, setResourceCanvasHistoryState] = useState<{ + scopeKey: string; + history: ResourceCanvasHistory; + }>(() => ({ + scopeKey: resourceCanvasHistoryScopeKey, + history: createResourceCanvasHistory(), + })); + const resourceCanvasHistory = + resourceCanvasHistoryState.scopeKey === resourceCanvasHistoryScopeKey + ? resourceCanvasHistoryState.history + : EMPTY_RESOURCE_CANVAS_HISTORY; + const setResourceCanvasHistory = useCallback( + ( + update: + | ResourceCanvasHistory + | ((history: ResourceCanvasHistory) => ResourceCanvasHistory), + ) => { + setResourceCanvasHistoryState((current) => ({ + scopeKey: resourceCanvasHistoryScopeKey, + history: + typeof update === 'function' + ? update( + current.scopeKey === resourceCanvasHistoryScopeKey + ? current.history + : createResourceCanvasHistory(), + ) + : update, + })); + }, + [resourceCanvasHistoryScopeKey], ); const [resourcePanelOpen, setResourcePanelOpen] = useState(false); const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = @@ -6447,8 +6494,6 @@ export default function ProjectDevelopmentView({ width: number; height: number; rotation: number; - dragX: number; - dragY: number; }, ) => { const previewIdentity = resourceCardPreviews.identityByResourceId.get( diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index 454c05448..4f2d85c16 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -34,8 +34,6 @@ export type ResourceBookCardLayout = { y: number; width: number; height: number; - dragX: number; - dragY: number; rotation: number; }; @@ -265,33 +263,6 @@ export function resolveResourceBookAllLayout( return { key, layout: buildResourceBookAllLayout(input) }; } -/** - * 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。 - * - * 展开态按分带铺卡片,卡片画的是"局部坐标 + 带原点";而布局 sidecar 只认栏目内局部坐标 - * (`resource-layouts/{dependency,type}.json` 的 `x / y`,`section` 是资源当前分类)。 - * 因此拖动提交前减掉带原点:写回的是**这张卡在它自己栏目里的同一个槽位**,栏目页立刻看到 - * 同一位置,不存在第二份空间。带不存在(栏目页、或该栏目此刻没有分带)时减 0, - * 与栏目页本来就有的口径完全一致。 - */ -export function resourceBookAllBandLocalPoint({ - layout, - category, - x, - y, -}: { - layout: ResourceBookAllLayout | null; - category: ResourceBookCategory; - x: number; - y: number; -}) { - const band = layout?.bandByCategory.get(category) ?? null; - return { - x: x - (band?.originX ?? 0), - y: y - (band?.originY ?? 0), - }; -} - export function groupResourceBookResourcesByCategory( resources: readonly ProjectResource[], ) { @@ -373,8 +344,6 @@ export function resourceBookOverviewCardLayout({ y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5, width: 92, height: 64, - dragX: 0, - dragY: 0, rotation: overviewStackRotations[rotationIndex] ?? 0, }; } @@ -396,8 +365,6 @@ export function resourceBookChildCardLayout({ y, width: size.width, height: size.height, - dragX: x, - dragY: y, rotation: 0, }; } @@ -502,8 +469,8 @@ export function buildResourceBookScenePlan({ ? (allLayout?.bandByCategory.get(category) ?? null) : null; /** - * 子画布布局 + 可选带偏移。偏移同时写进 `x / y` 与 `dragX / dragY`:后者是拖动起点, - * 必须与画出来的世界坐标同系;提交时再减回带原点(`resourceBookAllBandLocalPoint`)。 + * 子画布布局 + 可选带偏移。偏移写进 `x / y`:卡片画出来的就是世界坐标,拖动按同一份世界 + * 位移换算成栏目内局部坐标落盘(位移在带原点冻结的一次拖动内逐卡拉平)。 */ const childLayoutFor = ( category: ResourceBookTarget, @@ -519,7 +486,7 @@ export function buildResourceBookScenePlan({ } const x = layout.x + band.originX; const y = layout.y + band.originY; - return { ...layout, x, y, dragX: x, dragY: y }; + return { ...layout, x, y }; }; return visibleCategoryOrder.map((category) => { const presentation = resourceBookCategoryCardPresentation(state, category); diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 31f4ce99d..074fda6f3 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -66,7 +66,8 @@ type ManualLayoutWriteIntent = { }; /** - * 自动坐标重派生请求。 + * 自动坐标重派生请求。两种重算**各占一个队列槽**(按 `kind` 区分):显式整理是用户动作、 + * 「关系图首次就绪」是系统动作,后者不得就地改写前者已经按下、还没落盘的那一笔。 * * - `automatic`:只丢自动坐标,手动卡原地不动(关系图首次就绪那一次)。 * - `organize`:用户显式「整理画布」。目标栏目内的坐标**连手动一起丢**再重算,结果一律是 @@ -591,21 +592,28 @@ export function useProjectResourceCanvasLayout({ return; } const signature = resourceSignatureRef.current; - const queued = writeQueueRef.current.find( + /** + * 队列里可能同时排着两笔重算(显式整理与关系图首次就绪各占一个槽):资源签名是作用域级 + * 的事实,排队中的每一笔都要跟上,否则先落盘的那笔会把后一笔的签名留成过期值、凭空多排 + * 一次资源同步。 + */ + const queued = writeQueueRef.current.filter( (intent): intent is ResourceLayoutWriteIntent => intent.kind === 'resources' && intent.scopeEpoch === scopeEpoch && intent !== activeWriteIntentRef.current, ); - if (queued) { - if (queued.resourceSignature !== signature) { - queued.resourceSignature = signature; - queued.conflictRetries = 0; - } else { - queued.conflictRetries = Math.min( - queued.conflictRetries, - conflictRetries, - ); + if (queued.length > 0) { + for (const intent of queued) { + if (intent.resourceSignature !== signature) { + intent.resourceSignature = signature; + intent.conflictRetries = 0; + } else { + intent.conflictRetries = Math.min( + intent.conflictRetries, + conflictRetries, + ); + } } } else { writeQueueRef.current.push({ @@ -1127,7 +1135,7 @@ export function useProjectResourceCanvasLayout({ (intent): intent is ResourceLayoutWriteIntent => intent.kind === 'resources' && intent.scopeEpoch === scope.epoch && - intent.rederive !== null && + intent.rederive?.kind === 'automatic' && intent !== activeWriteIntentRef.current, ); if (queued) { @@ -1174,17 +1182,42 @@ export function useProjectResourceCanvasLayout({ if (sections !== null && sections.length === 0) { return false; } - // 先按同一套重算看结果:与当前画面逐值相同(含手动标记)就什么都不做。 - const previous = layoutRef.current; - const next = rebuildOptimisticLayout(scope.epoch, 'rederive', sections); - if (!next || positionsEqual(previous.positions, next.positions)) { + /** + * 「这一按到底会不会改变布局」按落盘口径判,而不是看乐观视图: + * + * - 主判据:整理结果 vs 已落盘布局(含手动标记)。与落盘那一步同源,两处判据不再打架。 + * - 补判据:目标栏目里还有排队中/在途的手动落点。这些落点会先落盘、随后被这次整理 + * 覆盖掉(整理丢的就是目标栏目的**全部**坐标,含手动),所以它本身就是一次真实变化。 + * 少了这一条,「刚拖完就按整理」会判成"什么都没变":用户的点击被静默吞掉,接下来 + * 落盘的手动结果反客为主。基准取乐观视图同样不行——那里叠着这批落点,等于自己抵消。 + */ + const organized = reconcileLayout( + persistedLayoutRef.current, + resourcesRef.current, + 'rederive', + topologyRef.current, + sections, + ); + const hasPendingManualDropInScope = writeQueueRef.current.some( + (intent) => + intent.kind === 'manual' && + intent.scopeEpoch === scope.epoch && + intent.positions.some( + (write) => sections === null || sections.includes(write.section), + ), + ); + if (!organized.changed && !hasPendingManualDropInScope) { + return false; + } + // 乐观视图按同一套重算落到画布上;排队中的手动落点仍在它之后落盘。 + if (!rebuildOptimisticLayout(scope.epoch, 'rederive', sections)) { return false; } const queued = writeQueueRef.current.find( (intent): intent is ResourceLayoutWriteIntent => intent.kind === 'resources' && intent.scopeEpoch === scope.epoch && - intent.rederive !== null && + intent.rederive?.kind === 'organize' && intent !== activeWriteIntentRef.current, ); if (queued) { diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index cd3cfe204..e2099510f 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -7,8 +7,6 @@ import { resolveResourceBookAllLayout, RESOURCE_BOOK_ALL_BAND_GAP, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, - type ResourceBookAllBand, - resourceBookAllBandLocalPoint, type ResourceBookAllLayout, resourceBookAllLayoutKey, resourceBookOverviewCardLayout, @@ -270,8 +268,6 @@ describe('buildResourceBookScenePlan', () => { y: 0, width: 180, height: 128, - dragX: 0, - dragY: 0, rotation: 0, }, }, @@ -288,8 +284,6 @@ describe('buildResourceBookScenePlan', () => { y: 0, width: 180, height: 128, - dragX: 0, - dragY: 0, rotation: 0, }, }, @@ -464,12 +458,11 @@ describe('buildResourceBookScenePlan', () => { // 世界原点叠成一摞(用户报的正是这个)。 expect(artGroup.titlebar).toBe(false); expect(artGroup.titlebarActive).toBe(false); - // 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。 + // 画出来的是"栏目内局部坐标 + 带原点":拖动按同一份世界位移换算成栏目内局部坐标落盘, + // 所以画出来的世界坐标必须等于"sidecar 里的局部坐标 + 带原点"。 const first = artGroup.cards[0]!; expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX); expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY); - expect(first.layout.dragX).toBe(first.layout.x); - expect(first.layout.dragY).toBe(first.layout.y); // 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。 const sceneCard = plan @@ -643,56 +636,6 @@ describe('buildResourceBookAllLayout', () => { }); }); -describe('resourceBookAllBandLocalPoint', () => { - const band: ResourceBookAllBand = { - category: 'document', - originX: 40, - originY: 100, - width: 620, - height: 200, - }; - const layout: ResourceBookAllLayout = { - bands: [band], - bandByCategory: new Map([['document', band]]), - bounds: { x: 0, y: 0, width: 620, height: 300 }, - }; - - /** - * 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点", - * 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。 - */ - it('subtracts the band origin so the world point lands back on the column-local point', () => { - expect( - resourceBookAllBandLocalPoint({ - layout, - category: 'document', - x: 140, - y: 260, - }), - ).toEqual({ x: 100, y: 160 }); - }); - - it('passes the point through for the column page and for a category without a band', () => { - // 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。 - expect( - resourceBookAllBandLocalPoint({ - layout: null, - category: 'document', - x: 140, - y: 260, - }), - ).toEqual({ x: 140, y: 260 }); - expect( - resourceBookAllBandLocalPoint({ - layout, - category: 'audio', - x: 7, - y: 9, - }), - ).toEqual({ x: 7, y: 9 }); - }); -}); - /** * 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。 * diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index dff9a10a1..dc734438a 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -1824,4 +1824,89 @@ describe('资源画布多选拖动与整理范围', () => { ).toEqual([]); await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([])); }); + + /** + * 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。 + * + * 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把 + * type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的 + * 布局。 + */ + it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => { + const { manager, tauri } = await mountPointerWorkbench('character', { + layoutByMode: { + type: [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 400, + y: 300, + manuallyPlaced: false, + }, + ], + dependency: [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 700, + y: 700, + manuallyPlaced: false, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + }, + }); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(), + ); + await settleFocusChain(); + + // 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。 + const cardA = cardIn(manager, 'asset:pointer-a'); + fireEvent.pointerDown(cardA, { + pointerId: 61, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardA, { + pointerId: 61, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(cardA, { + pointerId: 61, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + await settleFocusChain(); + + // 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。 + fireEvent.click(screen.getByRole('button', { name: '按依赖' })); + await settleFocusChain(); + const dependencyWritesBefore = dependencyWrites(tauri).length; + + fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); + await settleFocusChain(); + + // 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。 + expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore); + }); }); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 54062db15..ab6201a00 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1595,6 +1595,53 @@ describe('资源画布整理与批量坐标写入', () => { updates.length = 0; } + /** + * 可挂起的 type 侧 harness:`holdNextWrite()` 之后的那一笔 update 会停在途上,直到 + * `releaseHeldWrite()` 放行。用来复现"上一笔还没落盘、用户又按了整理"的真实窗口。 + */ + function gatedTypeLayoutHarness( + initialPositions: ProjectResourceCanvasPosition[], + ) { + const updates: ProjectResourceCanvasPosition[][] = []; + let heldWrite: (() => void) | null = null; + let holdNext = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('type', 7, structuredClone(initialPositions)); + } + if (command === 'update_local_project_resource_canvas_layout') { + if (holdNext) { + holdNext = false; + await new Promise((resolve) => { + heldWrite = resolve; + }); + } + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('type', 8 + updates.length, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + return { + invoke, + updates, + holdNextWrite: () => { + holdNext = true; + }, + releaseHeldWrite: () => { + heldWrite?.(); + }, + }; + } + it('整理栏目时连手动坐标一起重算,其他栏目逐值不动', async () => { const documentA = resource('resource-doc-a'); const documentB = { ...resource('resource-doc-b'), dependencyDepth: 1 }; @@ -1741,6 +1788,222 @@ describe('资源画布整理与批量坐标写入', () => { ); }); + /** + * 排队中的「整理画布」不能被「关系图首次就绪」那一次重算覆盖。 + * + * 两种重算共用同一条写队列:显式整理是用户动作、首次就绪是系统动作,各自占一个队列槽。 + * 系统请求若就地改写用户已经按下、还没落盘的那一笔,用户会看到整理生效、落盘却把手动卡 + * 留在原地(整理被悄悄降级成"只丢自动坐标")。 + */ + it('排队中的整理不会被「关系图首次就绪」覆盖:两者各占一个队列槽', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = + gatedTypeLayoutHarness([ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ]); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 让一笔手动写入停在途上:后面的整理只能排队——正是「整理已按下、还没落盘」的窗口。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + // 关系图首次就绪的重算在这个窗口里进来。 + act(() => { + result.current.rederiveNow(); + }); + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates.length).toBeGreaterThan(1)); + // 整理那一笔照原样落盘:栏内两张卡都重算成自动坐标,手动坐标不残留。 + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + // 首次就绪那一笔退化成空操作:整理后已经是自动坐标,不再多写一次 CAS。 + expect(updates).toHaveLength(2); + }); + + /** + * 「刚拖完、落点还没写回」时按整理:整理必须照常排进队列并最终生效。 + * + * 判据不能拿乐观视图(已经把那一笔排队中的手动落点叠上去了)去比:整理结果与它逐值相同 + * 就会判成"什么都没变",用户的点击被静默吞掉,随后落盘的手动结果(卡在原地)反客为主。 + * 与落盘那一步同源地用"整理结果 vs 已落盘布局"判定,两处才不会再打架。 + */ + it('拖动落点还在途时按整理:整理照常排队,最终把这张卡重排回自动槽位', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = + gatedTypeLayoutHarness([ + automaticPosition('resource-doc-a', 0, 0), + automaticPosition('resource-doc-b', 196, 0), + ]); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 拖动落点已经在画面上(乐观视图),但这一笔还没写回。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 500, 600); + }); + + // 用户紧接着按整理:这一按必须真的排进队列。 + let organized = false; + act(() => { + organized = result.current.organizeNow(['document']); + }); + expect(organized).toBe(true); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates.length).toBeGreaterThan(1)); + // 整理排在手动落点之后落盘:这张卡被重排回自动槽位,手动落点不留在最终布局里。 + expect(updates.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + /** + * 分类刚变更、同步写还没落盘时拖动这张卡:落点必须按**新栏目**写回,不能被静默跳过。 + * + * 顺序上的依据:资源签名变化那一次 effect 先 `reconcileLayout(layoutRef.current, resources)` + * 再 `applyLayout`,而 `resources` 已经是新分类——所以拖动开始时内存布局里这条坐标的 + * `section` 已经是新栏目,`resourceId + section` 的匹配不会落空。这条用例把这个顺序钉住: + * 谁把顺序改回去(例如先写后 reconcile),这里就会先红。 + */ + it('分类刚变更、同步写还没落盘时拖动:落点按新栏目写回,不被静默跳过', async () => { + const documentResource = resource('resource-shift'); + const sceneResource: ResourceCanvasItem = { + ...resource('resource-shift'), + category: 'scene', + }; + const { updates, holdNextWrite, releaseHeldWrite } = + gatedTypeLayoutHarness([ + { ...automaticPosition('resource-shift', 100, 100), section: 'document' }, + ]); + const { result, rerender } = renderHook( + (props: { + resources: ResourceCanvasItem[]; + }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: props.resources, + }), + { initialProps: { resources: [documentResource] } }, + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 分类变更:挂住随之而来的同步写,模拟"变更已生效、sidecar 还没对齐"的窗口。 + holdNextWrite(); + act(() => { + rerender({ resources: [sceneResource] }); + }); + await act(async () => { + await Promise.resolve(); + }); + // 内存布局先按新分类归并:这条坐标的 section 已经是 scene。 + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ section: 'scene', x: 100, y: 100 }); + + // 窗口内拖动这张卡:乐观布局必须立刻跟上(匹配落空的话这里会停在 100,100)。 + act(() => { + result.current.commitPosition('resource-shift', 'scene', 300, 400); + }); + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ + section: 'scene', + x: 300, + y: 400, + manuallyPlaced: true, + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + // 落盘也是一笔带新栏目与手动标记的坐标:没有"被跳过、还没提示"的静默路径。 + await waitFor(() => expect(result.current.settled).toBe(true)); + const manualWrites = updates.filter((positions) => + positions.some( + (position) => + position.resourceId === 'resource-shift' && + position.x === 300 && + position.y === 400 && + position.manuallyPlaced, + ), + ); + expect(manualWrites).toHaveLength(1); + expect( + manualWrites[0]!.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ section: 'scene', x: 300, y: 400 }); + }); + it('没有可整理栏目时不产生任何写入', async () => { const documentA = resource('resource-doc-a'); const { updates } = typeLayoutHarness([position('resource-doc-a', 600, 40)]); From 13b28ebbc70aea977c556a3c59ad9b8d0394fcc4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:18:47 +0800 Subject: [PATCH 22/68] =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E4=B8=8E=E5=8D=A1=E4=B8=8B=E7=8B=AC=E7=AB=8B=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=BC=95=E7=94=A8=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E8=A2=AB=E7=84=A6=E7=82=B9=E9=99=B7=E9=98=B1=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具点击先在当前栏目创建临时占位卡,生成浮层挂在占位下沿;图片、音效、背景音乐入口一致 占位按项目与草稿 ID 归属宿主临时状态:可拖动、提交绑定任务 ID、成功结果落到占位最新位置 删除占位只隐藏展示,关闭浮层不冒充取消后台任务;失败保留占位与输入引用供重试 生成面板改为非模态浮层:ThemedModal 焦点陷阱曾阻断 portal 到 body 的引用选择器,点选不生效 引用选择器定位加顶边钳制,搜索收窄与键盘选择可用;不再越出视口 参考候选与陈旧引用校验排除 SVG(原生解码不支持),其余 image 类型不额外收窄 任务创建处对参考资产 ID 去重;补浮层真实组件整合、占位模型与拖动、参考模型回归测试 --- .../ResourceReferenceInput.tsx | 46 +- ...ResourceCanvasAssetGenerationPanelView.tsx | 68 ++- .../ResourceCanvasGenerationPanelView.tsx | 48 +- ...rceCanvasGenerationPlaceholderCardView.tsx | 95 +++ ...urceCanvasAssetGenerationReferenceModel.ts | 63 +- .../resourceCanvasAssetGenerationTaskModel.ts | 20 +- .../resourceCanvasGenerationPanel.css | 94 +++ ...esourceCanvasGenerationPlaceholderModel.ts | 262 +++++++++ ...useResourceCanvasGenerationPlaceholders.ts | 262 +++++++++ apps/ai-game-creator-shell/src/styles.css | 9 +- .../src/view/project-development/index.tsx | 543 ++++++++++++++++-- .../appSurface/project-development.suite.ts | 29 +- ...vasAssetGenerationBackgroundClose.test.tsx | 15 +- ...resourceCanvasAssetGenerationQueue.test.ts | 6 +- ...ceCanvasAssetGenerationReferences.test.tsx | 35 ++ .../resourceCanvasBottomToolbar.test.tsx | 30 +- ...urceCanvasGenerationFloatingPanel.test.tsx | 159 +++++ ...sourceCanvasGenerationPlaceholder.test.tsx | 340 +++++++++++ .../resourceGenerationPromptTestUtils.ts | 40 ++ 19 files changed, 2042 insertions(+), 122 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 10ac003cc..629c05a3a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -495,8 +495,9 @@ function ResourceReferenceEditor({ useState(null); const [pickerPosition, setPickerPosition] = useState<{ left: number; - bottom: number; + top: number; width: number; + maxHeight: number; } | null>(null); const rootRef = useRef(null); @@ -960,18 +961,46 @@ function ResourceReferenceEditor({ const updatePickerPosition = useCallback(() => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) return; + const viewportPadding = 12; + const gap = 8; const width = Math.min( Math.max(rect.width, 360), - Math.max(280, window.innerWidth - 24), + Math.max(280, window.innerWidth - viewportPadding * 2), ); const left = Math.min( - Math.max(12, rect.left), - Math.max(12, window.innerWidth - width - 12), + Math.max(viewportPadding, rect.left), + Math.max(viewportPadding, window.innerWidth - width - viewportPadding), ); + /** + * 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。 + * + * 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px, + * 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排 + * 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间, + * 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。 + */ + const availableAbove = Math.max(0, rect.top - viewportPadding - gap); + const availableBelow = Math.max( + 0, + window.innerHeight - rect.bottom - viewportPadding - gap, + ); + const openAbove = + availableAbove >= 200 || availableAbove >= availableBelow; + const maxHeight = Math.max( + 160, + Math.min(480, openAbove ? availableAbove : availableBelow), + ); + const top = openAbove + ? Math.max(viewportPadding, rect.top - gap - maxHeight) + : Math.min( + Math.max(viewportPadding, window.innerHeight - viewportPadding - maxHeight), + rect.bottom + gap, + ); setPickerPosition({ left, - bottom: Math.max(12, window.innerHeight - rect.top + 8), + top, width, + maxHeight, }); }, []); @@ -1096,10 +1125,15 @@ function ResourceReferenceEditor({ aria-modal="false" aria-label="选择素材" style={{ + // 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在 + // 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。 + position: 'fixed', + top: `${pickerPosition.top}px`, left: `${pickerPosition.left}px`, - bottom: `${pickerPosition.bottom}px`, right: 'auto', + bottom: 'auto', width: `${pickerPosition.width}px`, + maxHeight: `${pickerPosition.maxHeight}px`, }} >
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index ba079eb25..ede80749c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -1,5 +1,5 @@ import { Sparkles, X } from 'lucide-react'; -import { type FormEvent, useState } from 'react'; +import { type CSSProperties, type FormEvent, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -10,6 +10,7 @@ import type { } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { ThemedModal } from '../../components/modal/ThemedModal'; +import './resourceCanvasGenerationPanel.css'; import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput'; import type { ChatComposerDraft, @@ -26,6 +27,7 @@ import { resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIssue, resourceCanvasAssetGenerationUserReferenceLimit, } from './resourceCanvasAssetGenerationReferenceModel'; import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; @@ -72,6 +74,19 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { projectPath?: string; versions?: GameIterationVersion[]; activeVersionId?: string | null; + /** + * 呈现形态。 + * + * `modal`:既有居中弹层(`ThemedModal` + 焦点陷阱)。`floating`:挂在画布占位卡下沿的 + * **独立浮层**——工具点击先建占位,浮层只是它旁边的一块 UI。 + * + * 生成浮层必须走 `floating`:`ThemedModal` 的焦点陷阱会把 `@` 引用选择器(portal 到 body 的 + * `resource-reference-picker`)挡在陷阱之外,候选项点了不生效。浮层不是模态,因此不受这条限制; + * 「关闭浮层不等于取消后台任务」的语义也由浮层形态直接成立。 + */ + variant?: 'modal' | 'floating'; + /** 浮层形态的定位样式(贴着占位卡下沿,与快速编辑 / 信息浮层同一条锚点口径)。 */ + style?: CSSProperties | null; /** * 提交回调:**同步返回**,面板不等它的结果。 * @@ -109,6 +124,8 @@ export function ResourceCanvasAssetGenerationPanelView({ projectPath, versions, activeVersionId, + variant = 'modal', + style, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { @@ -147,6 +164,16 @@ export function ResourceCanvasAssetGenerationPanelView({ action, referenceCount: referenceAssetIds.length, }); + /* + 陈旧引用(素材被删 / 改了类型 / 没有本地文件)必须在提交前报出来:过滤掉再提交等于 + 把「带参考」变成「无参考」的付费生成,用户还以为参考生效了。 + */ + const referenceIssue = referenceEnabled + ? resourceCanvasAssetGenerationReferenceIssue({ + references, + assets: assets ?? [], + }) + : null; const promptTooLong = prompt.trim().length > promptMaxLength; const promptTooLongError = promptTooLong ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` @@ -155,8 +182,9 @@ export function ResourceCanvasAssetGenerationPanelView({ prompt.trim().length > 0 && assetName.trim().length > 0 && !referenceError && + !referenceIssue && !promptTooLong; - const shownError = error ?? promptTooLongError ?? referenceError; + const shownError = error ?? referenceIssue ?? promptTooLongError ?? referenceError; const applyDraft = (next: ChatComposerDraft) => { setPrompt(next.text); setReferences(next.references); @@ -171,6 +199,7 @@ export function ResourceCanvasAssetGenerationPanelView({ !normalizedPrompt || !normalizedAssetName || referenceError || + referenceIssue || promptTooLong ) { return; @@ -189,13 +218,8 @@ export function ResourceCanvasAssetGenerationPanelView({ onClose(); } - return ( - + const panelBody = ( + <>

{action.label}

@@ -327,6 +351,32 @@ export function ResourceCanvasAssetGenerationPanelView({
+ + ); + + if (variant === 'floating') { + return ( +
event.stopPropagation()} + > + {panelBody} +
+ ); + } + + return ( + + {panelBody} ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index ad3eaad36..f94415b08 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -1,5 +1,5 @@ import { Sparkles, X } from 'lucide-react'; -import { type FormEvent, useRef, useState } from 'react'; +import { type CSSProperties, type FormEvent, useRef, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -36,6 +36,15 @@ export type ResourceCanvasGenerationPanelViewProps = { */ kinds?: readonly ResourceCanvasGenerationKind[]; initialKind?: ResourceCanvasGenerationKind; + /** + * 呈现形态。 + * + * 面板底部栏目的音频入口(音效 / 背景音乐)与「生成素材」入口一样:工具点击先在当前栏目 + * 建占位卡,浮层挂在占位卡下沿。占位与浮层的归属由宿主按 `draftId` 维护,面板只负责这一份 + * 草稿与失败重试。 + */ + variant?: 'modal' | 'floating'; + style?: CSSProperties | null; onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; onClose: () => void; }; @@ -66,6 +75,8 @@ function resourceGenerationErrorMessage(error: unknown) { export function ResourceCanvasGenerationPanelView({ kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind), initialKind, + variant = 'modal', + style, onSubmit, onClose, }: ResourceCanvasGenerationPanelViewProps) { @@ -125,13 +136,8 @@ export function ResourceCanvasGenerationPanelView({ } } - return ( - + const panelBody = ( + <>

{panelTitle}

@@ -222,6 +228,32 @@ export function ResourceCanvasGenerationPanelView({ )}
+ + ); + + if (variant === 'floating') { + return ( +
event.stopPropagation()} + > + {panelBody} +
+ ); + } + + return ( + + {panelBody} ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx new file mode 100644 index 000000000..f10a95d36 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx @@ -0,0 +1,95 @@ +import { Sparkles, X } from 'lucide-react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; + +import './resourceCanvasGenerationPanel.css'; +import { + RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS, + type ResourceCanvasGenerationPlaceholder, +} from './resourceCanvasGenerationPlaceholderModel'; + +export type ResourceCanvasGenerationPlaceholderCardViewProps = { + placeholder: ResourceCanvasGenerationPlaceholder; + /** 生成浮层是否正挂在这张占位下面:决定卡片的高亮与浮层开合。 */ + active: boolean; + /** 拖动中:卡片只跟指针走,不参与任何过渡。 */ + dragging: boolean; + onPointerDown: (event: ReactPointerEvent) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + /** 点击卡片(不是删除按钮):打开 / 收起挂在它下面的生成浮层。 */ + onTogglePanel: () => void; + /** 删除占位:只隐藏展示,不取消后台任务。 */ + onRemove: () => void; +}; + +/** + * 画布上的生成占位卡。 + * + * 它**不是**正式资源卡:没有 manifest 身份、没有预览、不参与资源投影与布局 sidecar, + * 只在宿主临时状态里活到「提交成功落卡」或「用户删掉它」为止。点击它开 / 收挂在它下沿的 + * 独立生成浮层,拖动改的是宿主内存里的坐标(结果卡最终落在同一条最新位置)。 + * + * 指针事件的接管只到本组件为止:`stopPropagation` 阻止画布的框选 / 平移当作空白处处理。 + */ +export function ResourceCanvasGenerationPlaceholderCardView({ + placeholder, + active, + dragging, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onTogglePanel, + onRemove, +}: ResourceCanvasGenerationPlaceholderCardViewProps) { + return ( +
{ + event.stopPropagation(); + onTogglePanel(); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + onTogglePanel(); + }} + > +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts index 0af767737..9fbac5e64 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts @@ -65,12 +65,29 @@ export function resourceCanvasAssetGenerationReferenceAssets( ): GameCreationAppAssetManifestEntry[] { return assets.filter( (asset) => - asset.mediaType.startsWith('image/') && + resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType) && asset.localPath.trim().length > 0 && !asset.localPath.startsWith('.agent/'), ); } +/** + * 参考图只收**原生真的能读**的栅格图片。 + * + * 原生侧按 `image::load_from_memory` 解码参考图,它不认识 SVG:把 `.svg` 放进候选,用户选中后 + * 提交必失败(而且是一次付费请求的失败)。所以这里显式排除 SVG,其余 `image/*` 一律放行—— + * 不做「只许 png/jpeg」这种凭空收窄,真实支持范围由原生解码器决定。 + */ +export function resourceCanvasAssetGenerationReferenceMediaTypeSupported( + mediaType: string, +): boolean { + const normalized = mediaType.trim().toLowerCase(); + if (!normalized.startsWith('image/')) { + return false; + } + return normalized !== 'image/svg+xml' && normalized !== 'image/svg'; +} + /** * 从草稿里的引用列表取出本次生成的参考资源 ID。 * @@ -119,3 +136,47 @@ export function resourceCanvasAssetGenerationReferenceError({ ? `已选 ${referenceCount} 张参考图;该入口会带上权威规范图,用户参考最多 ${limit} 张` : `已选 ${referenceCount} 张参考图;最多 ${limit} 张`; } + +/** + * 提交前的**陈旧引用**判据:草稿里的引用必须仍然是当前项目已登记、有本地文件的图片。 + * + * 这一步不能省、也不能用过滤糊过去:草稿可能是在素材被删掉 / 改了类型之后才提交的, + * 静默过滤会让用户以为「带了那张参考」实际却发了一次无参考的付费生成。所以这里给出明确原因、 + * 挡住提交,草稿与 `@显示名` 正文都原样保留,由用户自己决定移除还是重选。 + */ +export function resourceCanvasAssetGenerationReferenceIssue({ + references, + assets, +}: { + references: readonly ChatReference[]; + assets: readonly GameCreationAppAssetManifestEntry[]; +}): string | null { + for (const reference of references) { + if (reference.type !== 'resource') { + continue; + } + const resourceId = reference.resourceId.trim(); + if (!resourceId) { + return '参考图引用缺少资源身份,请重新选择后再提交'; + } + const asset = assets.find((item) => item.id === resourceId); + if (!asset) { + return `参考图「${reference.label}」已不在当前项目,请移除后再提交`; + } + if (!asset.mediaType.startsWith('image/')) { + return `参考图「${reference.label}」不是图片,不能作为生成参考`; + } + if ( + !resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType) + ) { + return `参考图「${reference.label}」是矢量图(${asset.mediaType}),暂不支持作为生成参考,请换栅格图片`; + } + if ( + !asset.localPath.trim() || + asset.localPath.startsWith('.agent/') + ) { + return `参考图「${reference.label}」没有本地文件,无法作为参考传递`; + } + } + return null; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index 1bb3b32c9..a6c4765f0 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -34,6 +34,13 @@ export type LocalProjectAssetGenerationTaskRecord = { export type ResourceCanvasAssetGenerationTask = { /** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */ taskId: string; + /** + * 这张任务是从哪个生成占位提交的。 + * + * 成功落点要用**占位的最新位置**、失败重试也要回到同一张占位,所以这条归属必须跟着任务走; + * 账本里没有它(后端不认占位),恢复出来的历史任务按 `null` 读。 + */ + draftId: string | null; actionId: string; actionLabel: string; assetKind: string; @@ -151,6 +158,7 @@ export function resourceCanvasAssetGenerationTaskIsTerminal( /** 新提交的任务:先本地排队,派发之前不进后端账本。 */ export function createResourceCanvasAssetGenerationTask(input: { taskId: string; + draftId?: string | null; action: ResourceCanvasAssetToolAction; prompt: string; assetName: string; @@ -163,6 +171,7 @@ export function createResourceCanvasAssetGenerationTask(input: { }): ResourceCanvasAssetGenerationTask { return { taskId: input.taskId, + draftId: input.draftId ?? null, actionId: input.action.id, actionLabel: input.action.label, assetKind: input.action.assetKind, @@ -170,7 +179,15 @@ export function createResourceCanvasAssetGenerationTask(input: { prompt: input.prompt, aspectRatio: input.aspectRatio, imageSize: input.imageSize, - referenceAssetIds: [...(input.referenceAssetIds ?? [])], + // 参考图去重(保持用户选择顺序):同一张素材在一份草稿里被选两次仍只算一次参考, + // 底层工厂也按这条口径收口,不把重复项留给原生侧与远端。 + referenceAssetIds: [ + ...new Set( + (input.referenceAssetIds ?? []) + .map((assetId) => assetId.trim()) + .filter((assetId) => assetId.length > 0), + ), + ], outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -193,6 +210,7 @@ export function restoreResourceCanvasAssetGenerationTask( ): ResourceCanvasAssetGenerationTask { return { taskId: record.taskId, + draftId: null, actionId: `restored:${record.kind}`, actionLabel: resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css new file mode 100644 index 000000000..f0b973778 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css @@ -0,0 +1,94 @@ +/* + * 生成占位卡与「卡下独立浮层」的局部样式。 + * + * 只服务栏目画布上的临时占位(宿主内存态)与挂在它下沿的生成浮层:两者都不是正式素材, + * 所以样式也刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 + * 放在独立文件里而不是并进 `resourceCanvasChrome.css`:这条链路可以整体回滚, + * 也不与画布手势/卡片展示的改动互相冲突。 + */ + +.game-resource-generation-placeholder { + position: absolute; + display: grid; + place-items: center; + align-content: center; + gap: 4px; + padding: 8px; + border: 1px dashed #c9a493; + border-radius: 14px; + background: rgb(255 250 247 / 88%); + color: #8a6a5c; + text-align: center; + cursor: grab; + user-select: none; + touch-action: none; +} + +.game-resource-generation-placeholder.is-active { + border-color: #e2835a; + box-shadow: 0 10px 26px rgb(62 37 27 / 18%); + color: #6f4a3b; +} + +.game-resource-generation-placeholder.is-dragging { + cursor: grabbing; + box-shadow: 0 16px 32px rgb(62 37 27 / 24%); +} + +.game-resource-generation-placeholder > strong { + max-width: 100%; + overflow: hidden; + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-generation-placeholder > small { + font-size: 11px; + color: #a68a7d; +} + +.game-resource-generation-placeholder > button { + position: absolute; + top: 4px; + right: 4px; + display: grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.game-resource-generation-placeholder > button:hover { + background: rgb(62 37 27 / 10%); +} + +/* + * 独立浮层:定位由宿主按占位卡下沿算好(与快速编辑 / 信息浮层同一条锚点口径), + * 所以这里只负责面板外观与「不被画布手势当空白」的层级。 + */ +.resource-canvas-generation-floating-panel { + position: absolute; + z-index: 70; + transform: translateX(-50%); + max-height: min(560px, calc(100dvh - 120px)); + overflow: auto; + pointer-events: auto; +} + +.resource-canvas-asset-generation-prompt-input { + max-height: 180px; + overflow: auto; +} + +.resource-canvas-asset-generation-reference-hint { + margin: 0; + color: #9a7d70; + font-size: 11px; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts new file mode 100644 index 000000000..c58445c80 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts @@ -0,0 +1,262 @@ +import type { CanvasLayer } from '../../../../../packages/image-canvas-core/src/types'; +import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types'; +import { + type CanvasOverlayStyle, + resolveQuickEditPanelStyle, +} from '../../../../../packages/image-canvas-core/src/overlays'; +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, + RESOURCE_CANVAS_ROW_GAP, + type ResourceCanvasCardSize, +} from '../../view/project-development/resourceCanvasLayoutModel'; +import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel'; +import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationModel'; + +/** + * 挂在这张占位下的生成浮层是谁。 + * + * 工具点击时就把「哪块面板 + 哪份草稿」一起记在占位上,所以收起浮层后再点占位卡能精确回到 + * 同一块面板与同一份输入(而不是重新猜一次默认参数)。 + */ +export type ResourceCanvasGenerationPlaceholderPanel = + | { route: 'asset'; action: ResourceCanvasAssetToolAction } + | { + route: 'audio'; + kinds: readonly ResourceCanvasGenerationKind[]; + initialKind: ResourceCanvasGenerationKind; + }; + +/** + * 生成占位卡的状态。 + * + * - `draft`:工具刚点开、请求还没交给后端;关掉浮层等于放弃这次草稿。 + * - `submitted`:已提交,任务在后台跑;关掉浮层**不等于**取消,占位继续显示在途。 + * - `failed`:这次生成失败;占位留在画布上,点它可以用同一份输入与引用重试。 + */ +export type ResourceCanvasGenerationPlaceholderStatus = + | 'draft' + | 'submitted' + | 'failed'; + +/** + * 工具点击后先在当前栏目创建的临时占位卡。 + * + * 它是**宿主临时状态**,不是正式素材:不登记 manifest、不进 Agent 可引用资源集、不写布局 + * sidecar,重开项目也不恢复位置。归属键是 `projectId + draftId`:切项目清掉上一份会话里未提交 + * 的草稿与界面位置;提交后用 `taskId` 关联后台任务,成功结果落到它的最新位置。 + */ +export type ResourceCanvasGenerationPlaceholder = { + /** 本次草稿的独立身份;同一个占位的重试沿用同一个 draftId。 */ + draftId: string; + /** 归属项目:切项目时未提交草稿与界面位置一并作废。 */ + projectId: string; + /** 占位所在的栏目(工具点击时用户正在看的那个栏目)。 */ + category: ProjectResourceCanvasCategory; + actionId: string; + actionLabel: string; + assetName: string; + /** 点这张占位要重新挂上的浮层(工具点击那一刻的动作,含全部默认参数)。 */ + panel: ResourceCanvasGenerationPlaceholderPanel; + /** 画布局部坐标;与同栏目资源卡同一坐标系,拖动只改这里。 */ + x: number; + y: number; + width: number; + height: number; + /** 提交后绑定的生成任务 id;未提交为 null。 */ + taskId: string | null; + status: ResourceCanvasGenerationPlaceholderStatus; + /** 失败原因(仅失败态有);重试面板据此给出同一条原因。 */ + error: string | null; +}; + +/** 占位卡的默认尺寸:与既有资源卡默认格同口径,占位与结果卡不会一大一小。 */ +export function resourceCanvasGenerationPlaceholderSize(): ResourceCanvasCardSize { + return { + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, + }; +} + +type OccupiedPlaceholderRect = { + x: number; + y: number; + width: number; + height: number; +}; + +/** + * 新占位的落点:当前栏目已有内容(资源卡与同栏目其它占位)**下方**的第一个空位。 + * + * 不用「屏幕中心」这类落点:占位必须落在它能被拖动、也能被结果接管的栏目局部坐标里, + * 而栏目内容按行铺开,追加在最后一行之下既不会盖住已有卡片,也和自动补位的方向一致。 + * 空栏目直接落在原点。 + */ +export function placeResourceCanvasGenerationPlaceholder({ + occupied, +}: { + occupied: readonly OccupiedPlaceholderRect[]; +}): { x: number; y: number } { + const bottom = occupied.reduce( + (current, rect) => Math.max(current, rect.y + rect.height), + Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(bottom)) { + return { x: 0, y: 0 }; + } + const left = occupied.reduce( + (current, rect) => Math.min(current, rect.x), + Number.POSITIVE_INFINITY, + ); + return { + x: Math.round(Number.isFinite(left) ? Math.max(0, left) : 0), + y: Math.round(bottom + RESOURCE_CANVAS_ROW_GAP), + }; +} + +/** 拖动落点:与布局模型同一套有限数与范围收口,占位不会被拖到坐标域之外。 */ +export function moveResourceCanvasGenerationPlaceholder( + placeholder: ResourceCanvasGenerationPlaceholder, + x: number, + y: number, +): ResourceCanvasGenerationPlaceholder { + const clamp = (value: number) => + Math.min( + GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(value)), + ); + return { + ...placeholder, + x: clamp(Number.isFinite(x) ? x : placeholder.x), + y: clamp(Number.isFinite(y) ? y : placeholder.y), + }; +} + +/** 提交:把占位与后台任务绑定,之后的终局都由 taskId 找回它。 */ +export function bindResourceCanvasGenerationPlaceholderTask( + placeholder: ResourceCanvasGenerationPlaceholder, + taskId: string, +): ResourceCanvasGenerationPlaceholder { + return { ...placeholder, taskId, status: 'submitted', error: null }; +} + +/** + * 任务失败:占位留在画布上等重试,状态与原因跟着后端记录走。 + * + * 失败**不**删占位:删掉就等于把用户这次输入与位置一起丢了,而重试恰恰要用同一份输入。 + */ +export function failResourceCanvasGenerationPlaceholder( + placeholder: ResourceCanvasGenerationPlaceholder, + error: string | null, +): ResourceCanvasGenerationPlaceholder { + return { ...placeholder, status: 'failed', error }; +} + +/** + * 删除占位。 + * + * 只把这一条从宿主临时状态里去掉,**不**取消后台任务、也不丢弃正式结果:已提交的任务继续在账本里 + * 跑完并把结果登记进项目(见里程碑「删除占位只隐藏展示」)。 + */ +export function removeResourceCanvasGenerationPlaceholder( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + draftId: string, +): ResourceCanvasGenerationPlaceholder[] { + return placeholders.filter((placeholder) => placeholder.draftId !== draftId); +} + +/** 切项目:只留当前项目的占位(未提交草稿与界面位置都不跨项目)。 */ +export function resourceCanvasGenerationPlaceholdersForProject( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + projectId: string, +): ResourceCanvasGenerationPlaceholder[] { + return placeholders.filter((placeholder) => placeholder.projectId === projectId); +} + +/** 按任务找回占位:成功落点与失败收口都以 taskId 为准,不按素材名猜。 */ +export function resourceCanvasGenerationPlaceholderByTaskId( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + taskId: string, +): ResourceCanvasGenerationPlaceholder | null { + return placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null; +} + +export function resourceCanvasGenerationPlaceholderByDraftId( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + draftId: string, +): ResourceCanvasGenerationPlaceholder | null { + return placeholders.find((placeholder) => placeholder.draftId === draftId) ?? null; +} + +export const RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS: Record< + ResourceCanvasGenerationPlaceholderStatus, + string +> = { + draft: '待提交', + submitted: '生成中', + failed: '生成失败', +}; + +/** + * 占位卡的浮层锚点层:与快速编辑 / 信息浮层共用同一条几何口径(贴着卡片下沿居中)。 + * + * 占位不是正式资源,没有 manifest 身份,所以这里只造一个仅供锚点算法使用的壳, + * 不把它塞进资源投影或布局模型。 + */ +export function resourceCanvasGenerationPlaceholderLayer( + placeholder: ResourceCanvasGenerationPlaceholder, +): CanvasLayer { + return { + id: placeholder.draftId, + resourceId: placeholder.draftId, + title: placeholder.assetName, + src: '', + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + originalWidth: placeholder.width, + originalHeight: placeholder.height, + zIndex: 0, + sourceType: 'uploaded', + }; +} + +/** + * 锚点探针:`resolveQuickEditPanelStyle` 只读 `panel` 判空,浮层自己不需要持有一份快速编辑 + * 专属状态(与信息浮层同一条做法)。 + */ +const RESOURCE_GENERATION_PANEL_ANCHOR_PROBE = { + sourceLayerId: '', + prompt: '', + size: '', + model: '', + status: 'idle', +} as const; + +/** + * 生成浮层的落点:贴着占位卡下沿居中,与快速编辑 / 信息浮层同一套几何。 + * + * 占位是可拖动的,所以浮层必须跟着卡走:这里每次都按占位当前坐标重算,浮层不会留在原地。 + */ +export function resolveResourceCanvasGenerationPanelStyle({ + placeholder, + viewport, + canvasSize, +}: { + placeholder: ResourceCanvasGenerationPlaceholder; + viewport: CanvasViewport; + canvasSize: { width: number; height: number }; +}): CanvasOverlayStyle | null { + return resolveQuickEditPanelStyle({ + panel: { ...RESOURCE_GENERATION_PANEL_ANCHOR_PROBE }, + sourceLayer: resourceCanvasGenerationPlaceholderLayer(placeholder), + viewport, + canvasSize, + }); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts new file mode 100644 index 000000000..52b58f0b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; + +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + bindResourceCanvasGenerationPlaceholderTask, + failResourceCanvasGenerationPlaceholder, + moveResourceCanvasGenerationPlaceholder, + placeResourceCanvasGenerationPlaceholder, + removeResourceCanvasGenerationPlaceholder, + resourceCanvasGenerationPlaceholderSize, + resourceCanvasGenerationPlaceholderByDraftId, + resourceCanvasGenerationPlaceholderByTaskId, + resourceCanvasGenerationPlaceholdersForProject, + type ResourceCanvasGenerationPlaceholder, + type ResourceCanvasGenerationPlaceholderPanel, +} from './resourceCanvasGenerationPlaceholderModel'; + +export type ResourceCanvasGenerationPlaceholderDraftInput = { + category: ProjectResourceCanvasCategory; + actionId: string; + actionLabel: string; + assetName: string; + panel: ResourceCanvasGenerationPlaceholderPanel; +}; + +type PlaceholderDragState = { + draftId: string; + pointerId: number; + captureTarget: HTMLElement; + startClientX: number; + startClientY: number; + /** 拖动前的画布坐标:取消手势要回到它,而不是回到落点。 */ + startX: number; + startY: number; + scale: number; + changed: boolean; +}; + +/** + * 生成占位卡的宿主状态:创建、绑定任务、失败收口、删除与拖动。 + * + * 全部是**内存态**:不登记 manifest、不写布局 sidecar、重开项目不恢复位置。归属键是 + * `projectId + draftId`,切项目只留当前项目的占位(未提交草稿与界面位置都不跨项目)。 + */ +export function useResourceCanvasGenerationPlaceholders({ + projectId, + resolvePlacement, + viewportScale, +}: { + projectId: string; + /** + * 新占位的落点。 + * + * 由宿主按「当前栏目已有卡片 + 同栏目其它占位」算出:占位坐标活在与资源卡同一个栏目局部 + * 坐标系里,只有宿主知道这一层的几何。 + */ + resolvePlacement: (input: { + category: ProjectResourceCanvasCategory; + width: number; + height: number; + placeholders: readonly ResourceCanvasGenerationPlaceholder[]; + }) => { x: number; y: number }; + /** 当前场景视口缩放:拖动位移按它换算成画布坐标。 */ + viewportScale: () => number; +}) { + const [placeholders, setPlaceholders] = useState< + ResourceCanvasGenerationPlaceholder[] + >([]); + const [draggingDraftId, setDraggingDraftId] = useState(null); + const placeholdersRef = useRef([]); + placeholdersRef.current = placeholders; + const dragRef = useRef(null); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + // 切项目:未提交草稿与界面位置一并作废,拖动中也要收掉,避免把上一份会话的占位写进新项目。 + useEffect(() => { + dragRef.current = null; + setDraggingDraftId(null); + setPlaceholders((current) => + resourceCanvasGenerationPlaceholdersForProject(current, projectId), + ); + }, [projectId]); + + const createPlaceholder = useCallback( + (input: ResourceCanvasGenerationPlaceholderDraftInput) => { + const size = resourceCanvasGenerationPlaceholderSize(); + const existing = resourceCanvasGenerationPlaceholdersForProject( + placeholdersRef.current, + projectIdRef.current, + ); + const placement = resolvePlacement({ + category: input.category, + width: size.width, + height: size.height, + placeholders: existing, + }); + const placeholder: ResourceCanvasGenerationPlaceholder = { + draftId: crypto.randomUUID(), + projectId: projectIdRef.current, + category: input.category, + actionId: input.actionId, + actionLabel: input.actionLabel, + assetName: input.assetName, + panel: input.panel, + x: placement.x, + y: placement.y, + width: size.width, + height: size.height, + taskId: null, + status: 'draft', + error: null, + }; + setPlaceholders((current) => [...current, placeholder]); + return placeholder; + }, + [resolvePlacement], + ); + + const bindTask = useCallback((draftId: string, taskId: string) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.draftId === draftId + ? bindResourceCanvasGenerationPlaceholderTask(placeholder, taskId) + : placeholder, + ), + ); + }, []); + + const failTask = useCallback((taskId: string, error: string | null) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.taskId === taskId + ? failResourceCanvasGenerationPlaceholder(placeholder, error) + : placeholder, + ), + ); + }, []); + + const remove = useCallback((draftId: string) => { + setPlaceholders((current) => + removeResourceCanvasGenerationPlaceholder(current, draftId), + ); + }, []); + + const move = useCallback((draftId: string, x: number, y: number) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.draftId === draftId + ? moveResourceCanvasGenerationPlaceholder(placeholder, x, y) + : placeholder, + ), + ); + }, []); + + const clearDrag = useCallback((pointerId: number) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== pointerId) { + return null; + } + if (drag.captureTarget.hasPointerCapture?.(pointerId)) { + drag.captureTarget.releasePointerCapture?.(pointerId); + } + dragRef.current = null; + setDraggingDraftId(null); + return drag; + }, []); + + /** + * 占位卡自己的指针拖动。 + * + * 与资源卡手势完全分开:占位不是资源、不进框选集合、也不参与多选移动,所以这里自己起一份 + * 手势状态,并把事件挡住(画布的空白处框选 / 平移不再当它是空白)。取消手势回到拖动前坐标。 + */ + const dragHandlersFor = useCallback( + (placeholder: ResourceCanvasGenerationPlaceholder) => ({ + onPointerDown: (event: ReactPointerEvent) => { + if (event.button !== 0 || event.isPrimary === false) { + return; + } + // 画布空白处的手势(框选 / 平移)不能被这张卡触发。 + event.stopPropagation(); + const current = resourceCanvasGenerationPlaceholderByDraftId( + placeholdersRef.current, + placeholder.draftId, + ); + if (!current) { + return; + } + const scale = viewportScale(); + event.currentTarget.setPointerCapture?.(event.pointerId); + dragRef.current = { + draftId: placeholder.draftId, + pointerId: event.pointerId, + captureTarget: event.currentTarget, + startClientX: event.clientX, + startClientY: event.clientY, + startX: current.x, + startY: current.y, + scale: Number.isFinite(scale) && scale > 0 ? scale : 1, + changed: false, + }; + setDraggingDraftId(placeholder.draftId); + }, + onPointerMove: (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + const nextX = drag.startX + (event.clientX - drag.startClientX) / drag.scale; + const nextY = drag.startY + (event.clientY - drag.startClientY) / drag.scale; + drag.changed = true; + move(drag.draftId, nextX, nextY); + }, + onPointerUp: (event: ReactPointerEvent) => { + const drag = clearDrag(event.pointerId); + if (!drag) { + return; + } + event.stopPropagation(); + }, + onPointerCancel: (event: ReactPointerEvent) => { + const drag = clearDrag(event.pointerId); + if (!drag) { + return; + } + // 取消手势(指针捕获丢失 / 窗口失焦):回到拖动前的位置,不留下半截坐标。 + move(drag.draftId, drag.startX, drag.startY); + }, + }), + [clearDrag, move, viewportScale], + ); + + const projectPlaceholders = useMemo( + () => resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId), + [placeholders, projectId], + ); + + return { + placeholders: projectPlaceholders, + placeholdersRef, + draggingDraftId, + createPlaceholder, + bindTask, + failTask, + remove, + move, + dragHandlersFor, + placeholderByTaskId: (taskId: string) => + resourceCanvasGenerationPlaceholderByTaskId( + placeholdersRef.current, + taskId, + ), + placeholderByDraftId: (draftId: string) => + resourceCanvasGenerationPlaceholderByDraftId( + placeholdersRef.current, + draftId, + ), + }; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b525ce3ac..ace34579e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5081,10 +5081,13 @@ h2 { } .resource-reference-picker { - position: absolute; + /* + * 定位与尺寸全部由组件按输入框上下可用空间算出来(fixed + top + maxHeight): + * 这里只保留兜底上界与外观。此前是 `absolute + bottom` 钉在输入框上方, + * 输入框靠上时整块面板的顶边会被顶出视口,顶部搜索与筛选看不见也点不到。 + */ + position: fixed; z-index: 80; - right: 0; - bottom: calc(100% + 8px); display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; width: min(440px, 88vw); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 80c67a98f..8f003568e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -78,6 +78,7 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, GameIterationVersion, + ProjectResourceCanvasCategory, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView'; @@ -113,6 +114,8 @@ import { isResourceReferenceOverlayTarget, resolveActiveIterationVersion, resourceReferenceCategoryLabel, + resourceReferenceFromAsset, + type ResourceReference, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { @@ -121,6 +124,13 @@ import { type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; +import { ResourceCanvasGenerationPlaceholderCardView } from '../../features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView'; +import { + placeResourceCanvasGenerationPlaceholder, + resolveResourceCanvasGenerationPanelStyle, + type ResourceCanvasGenerationPlaceholder, +} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; +import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -1741,11 +1751,16 @@ export default function ProjectDevelopmentView({ const [resourceGenerationDraft, setResourceGenerationDraft] = useState<{ initialKind: ResourceCanvasGenerationKind; kinds: readonly ResourceCanvasGenerationKind[]; + /** 这次生成挂在哪张占位卡下面(占位按 projectId + draftId 归属宿主临时状态)。 */ + draftId: string; } | null>(null); const resourceGenerationOpen = resourceGenerationDraft !== null; /** 工具栏图片类入口打开的生成浮层;同一时刻只允许一个。 */ - const [resourceAssetGenerationAction, setResourceAssetGenerationAction] = - useState(null); + const [resourceAssetGenerationPanel, setResourceAssetGenerationPanel] = + useState<{ + action: ResourceCanvasAssetToolAction; + draftId: string; + } | null>(null); /** * 图片类生成任务的本地队列 + 后端账本视图。 * @@ -1766,6 +1781,8 @@ export default function ProjectDevelopmentView({ */ const resourceAssetGenerationPanelSubmissionRef = useRef<{ taskId: string; + /** 这次提交挂在哪张占位卡下面:失败重开浮层要回到同一张占位。 */ + draftId: string; action: ResourceCanvasAssetToolAction; draft: ResourceCanvasAssetGenerationPanelDraft; dispatchedImmediately: boolean; @@ -1776,6 +1793,8 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationPanelReopen, ] = useState<{ actionId: string; + /** 重开时要挂回的那张占位:草稿、浮层与占位三者的归属键就是它。 */ + draftId: string; draft: ResourceCanvasAssetGenerationPanelDraft; error: string; } | null>(null); @@ -1783,6 +1802,79 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen, ] = useState(false); + /** + * 占位落点要用的几何:栏目位置表与卡片尺寸表。两者都在本渲染靠后处才算出来, + * 所以用 ref 暴露给占位 Hook(工具点击发生在那之后,读到的总是最新一帧)。 + */ + const resourceCanvasGenerationPlacementRef = useRef<{ + positionsByCategory: ReadonlyMap< + string, + readonly { resourceId: string; x: number; y: number }[] + >; + cardSizeByResourceId: ReadonlyMap< + string, + { width: number; height: number } + >; + } | null>(null); + /** + * 生成占位卡(工具点击先在当前栏目创建)。 + * + * 纯宿主临时状态:按 `projectId + draftId` 归属,不登记 manifest、不写布局 sidecar; + * 提交后按 `taskId` 关联后台任务,成功结果落到占位的最新位置,删除占位只隐藏展示。 + */ + const resourceGenerationPlaceholders = + useResourceCanvasGenerationPlaceholders({ + projectId: manifest.projectId, + /* + 落点用「当前栏目已有卡片 + 同栏目其它占位」的下方空位:这一层几何只有宿主知道, + 模型保持纯函数。 + */ + resolvePlacement: ({ + category, + width, + height, + placeholders: sameSectionPlaceholders, + }) => { + const context = resourceCanvasGenerationPlacementRef.current; + const positions = context?.positionsByCategory.get(category) ?? []; + const occupied = [ + ...positions.map((position) => ({ + x: position.x, + y: position.y, + width: + context?.cardSizeByResourceId.get(position.resourceId)?.width ?? + width, + height: + context?.cardSizeByResourceId.get(position.resourceId)?.height ?? + height, + })), + ...sameSectionPlaceholders + .filter((placeholder) => placeholder.category === category) + .map((placeholder) => ({ + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + })), + ]; + return placeResourceCanvasGenerationPlaceholder({ occupied }); + }, + viewportScale: () => resourceCanvasSceneViewportRef.current.scale ?? 1, + }); + /** + * 成功结果的落点意图:占位的最新位置。 + * + * 生成完成时新资源还没进布局(reconcile 补位发生在下一次渲染),此刻直接 `commitPosition` 会在 + * positions 里找不到它、被静默忽略。所以先记下意图,等这张卡真的进了布局再提交落点。 + */ + const resourceGenerationLandingRef = useRef<{ + projectId: string; + draftId: string; + resourceId: string; + category: ProjectResourceCanvasCategory; + x: number; + y: number; + } | null>(null); /** * 「定位到素材」的聚焦请求序号。 * @@ -2080,11 +2172,12 @@ export default function ProjectDevelopmentView({ /** * 画布宿主的生成浮层是否开着(「生成素材」与工具栏图片类入口共用同一口径)。 * - * 两块面板都是 portal 到 body 的模态浮层:它们开着时,「点外部清画布焦点」与画布自己的 - * Esc 都必须让位,否则点面板里的控件会被判成点外部、Esc 会同时关面板又清选中。 + * 两块面板现在都挂在占位卡下沿、活在画布视口坐标系里(不是模态)。它们开着时,「点外部清画布 + * 焦点」与画布自己的 Esc 仍然必须让位:面板里的控件(含 `@` 引用选择器)不能被判成点外部, + * Esc 也不能同时关面板又清选中。 */ const resourceCanvasHostGenerationPanelOpen = - resourceGenerationOpen || resourceAssetGenerationAction !== null; + resourceGenerationOpen || resourceAssetGenerationPanel !== null; /** * 「编辑素材标签」与「设置素材类型」两块面板**共用一个宿主浮层判据**: @@ -2734,6 +2827,44 @@ export default function ProjectDevelopmentView({ /** 拖动起点坐标的读取口:`pointerdown` 与渲染同源,避免两处各取一份布局快照。 */ const resourceLayoutPositionsRef = useRef(resourcePositionById); resourceLayoutPositionsRef.current = resourcePositionById; + /** + * 结果落点:把成功产物落到它那张占位的**最新位置**。 + * + * 等这张卡真的进了布局再提交坐标(生成完成时 reconcile 还没补位,提前提交会被静默忽略); + * 提交后撤掉占位——结果已经接管了它的位置。切项目时落点作废,不把旧项目的坐标写进新项目。 + */ + const removeResourceGenerationPlaceholder = + resourceGenerationPlaceholders.remove; + useEffect(() => { + const landing = resourceGenerationLandingRef.current; + if (!landing) { + return; + } + if ( + landing.projectId !== manifest.projectId || + landing.projectId !== resourceLayout.projectId + ) { + resourceGenerationLandingRef.current = null; + return; + } + if (!resourcePositionById.has(landing.resourceId)) { + return; + } + resourceGenerationLandingRef.current = null; + activeResourceLayout.commitPosition( + landing.resourceId, + landing.category, + landing.x, + landing.y, + ); + removeResourceGenerationPlaceholder(landing.draftId); + }, [ + activeResourceLayout, + manifest.projectId, + removeResourceGenerationPlaceholder, + resourceLayout.projectId, + resourcePositionById, + ]); const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( (resource) => resource.id === selectedResourceId, @@ -3078,6 +3209,12 @@ export default function ProjectDevelopmentView({ ), [resourceLayout.positions], ); + // 占位落点要用「当前栏目已有卡片」的几何:位置表与卡片尺寸表在这里才算出来, + // 所以在这一层暴露给占位 Hook(工具点击发生在渲染之后,读到的一定是最新一帧)。 + resourceCanvasGenerationPlacementRef.current = { + positionsByCategory: resourcePositionsByCategory, + cardSizeByResourceId: resourceCardSizeByResourceId, + }; const resourceBaseExtentByCategory = useMemo( () => new Map( @@ -7177,7 +7314,10 @@ export default function ProjectDevelopmentView({ * 已有 manifest 条目都不参与派生;成功后用 `pendingResourceFocusRef` 定位新卡。 */ const submitResourceCanvasGeneration = useCallback( - async (input: ResourceCanvasGenerationSubmitInput) => { + async ( + input: ResourceCanvasGenerationSubmitInput, + draftId: string | null = null, + ) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error('生成资源需要在客户端内执行'); @@ -7333,17 +7473,31 @@ export default function ProjectDevelopmentView({ settlement.record === null && submission.dispatchedImmediately ) { + // 后端从未受理:占位留在画布上,重开浮层带同一份草稿(含参考图)重试。 + resourceGenerationPlaceholders.failTask( + settlement.taskId, + settlement.error ?? '生成素材失败', + ); setResourceAssetGenerationPanelReopen({ actionId: submission.action.id, + draftId: submission.draftId, draft: submission.draft, error: settlement.error ?? '生成素材失败', }); - setResourceAssetGenerationAction(submission.action); + setResourceAssetGenerationPanel({ + action: submission.action, + draftId: submission.draftId, + }); setResourceWorkbenchNotice(''); return; } } if (settlement.status !== 'completed' || !settlement.record?.assetId) { + // 受理之后才失败:占位保留(名称、位置与重试入口都还在),后台任务在账本里收口。 + resourceGenerationPlaceholders.failTask( + settlement.taskId, + settlement.error ?? '未知原因', + ); setResourceWorkbenchNotice( `生成素材失败:${settlement.error ?? '未知原因'}`, ); @@ -7355,6 +7509,23 @@ export default function ProjectDevelopmentView({ setResourceWorkbenchNotice('生成结果需要在客户端内读取'); return; } + /* + 成功落点 = **占位的最新位置**。位置在这里冻结成意图,等新卡进了布局再提交坐标 + (生成完成时 reconcile 还没补位,此刻 commitPosition 会被静默忽略)。 + 占位已经被用户删掉时没有位置可接管,保持既有自动落位 + 定位行为。 + */ + const landingPlaceholder = + resourceGenerationPlaceholders.placeholderByTaskId(settlement.taskId); + if (landingPlaceholder) { + resourceGenerationLandingRef.current = { + projectId: landingPlaceholder.projectId, + draftId: landingPlaceholder.draftId, + resourceId: `asset:${assetId}`, + category: landingPlaceholder.category, + x: landingPlaceholder.x, + y: landingPlaceholder.y, + }; + } let fresh: Awaited< ReturnType > = null; @@ -7454,6 +7625,7 @@ export default function ProjectDevelopmentView({ ( action: ResourceCanvasAssetToolAction, input: ResourceCanvasAssetGenerationSubmitInput, + draftId: string, ) => { const queue = resourceAssetGenerationQueueRef.current; const context = resourceAssetGenerationContextRef.current; @@ -7473,6 +7645,8 @@ export default function ProjectDevelopmentView({ ); const task = createResourceCanvasAssetGenerationTask({ taskId: crypto.randomUUID(), + // 任务绑定它提交时那张占位:成功落点、失败重试都靠这条归属回到同一张占位卡。 + draftId, action, prompt: input.prompt, assetName: input.assetName, @@ -7492,6 +7666,7 @@ export default function ProjectDevelopmentView({ }); resourceAssetGenerationPanelSubmissionRef.current = { taskId: task.taskId, + draftId, action, draft: { prompt: input.prompt, @@ -7503,6 +7678,8 @@ export default function ProjectDevelopmentView({ dispatchedImmediately, }; setResourceAssetGenerationPanelReopen(null); + // 占位从「待提交」进入「生成中」:任务已经交给后台,关闭浮层不影响它。 + resourceGenerationPlaceholders.bindTask(draftId, task.taskId); setResourceAssetGenerationTasksPanelOpen(true); setResourceWorkbenchNotice( `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, @@ -7511,7 +7688,7 @@ export default function ProjectDevelopmentView({ // 避免出现未处理的 Promise 拒绝。 void queue.submit(task).catch(() => undefined); }, - [], + [resourceGenerationPlaceholders], ); /** @@ -7534,6 +7711,9 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationTasksPanelOpen(false); resourceAssetGenerationPanelSubmissionRef.current = null; setResourceAssetGenerationPanelReopen(null); + // 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。 + setResourceGenerationDraft(null); + setResourceAssetGenerationPanel(null); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -7824,21 +8004,188 @@ export default function ProjectDevelopmentView({ ).blockedReason; const handleResourceBottomToolAction = useCallback( (action: ResourceCanvasBottomToolAction) => { + /* + 工具点击**先在当前栏目创建临时占位卡**,浮层只是挂在它下沿的一块 UI: + 用户点下去立刻在画布上看到这次生成要落在哪儿,之后可以拖着它换位置, + 结果卡就落到占位的最新位置。占位不是正式素材,关闭浮层也不等于取消后台任务。 + */ + const category = resourceCanvasBottomToolbarCategory; + if (!category) { + return; + } if (action.route === 'audio') { + const placeholder = resourceGenerationPlaceholders.createPlaceholder({ + category, + actionId: action.id, + actionLabel: action.label, + assetName: action.assetName, + panel: { + route: 'audio', + kinds: [action.audioKind], + initialKind: action.audioKind, + }, + }); // 音频入口复用既有「生成素材」面板与同一条无源生成链路,只是各自只放行一种类型。 setResourceGenerationDraft({ initialKind: action.audioKind, kinds: [action.audioKind], + draftId: placeholder.draftId, }); return; } if (action.route === 'asset') { - setResourceAssetGenerationAction(action); + const placeholder = resourceGenerationPlaceholders.createPlaceholder({ + category, + actionId: action.id, + actionLabel: action.label, + assetName: action.assetName, + panel: { route: 'asset', action }, + }); + setResourceAssetGenerationPanel({ + action, + draftId: placeholder.draftId, + }); } }, + [resourceCanvasBottomToolbarCategory, resourceGenerationPlaceholders], + ); + + /** 当前挂着生成浮层的那张占位(音频与图片类入口共用同一个口径)。 */ + const resourceGenerationPanelDraftId = + resourceGenerationDraft?.draftId ?? + resourceAssetGenerationPanel?.draftId ?? + null; + const resourceGenerationPanelPlaceholder = resourceGenerationPanelDraftId + ? resourceGenerationPlaceholders.placeholderByDraftId( + resourceGenerationPanelDraftId, + ) + : null; + /** + * 浮层落点:贴着占位卡下沿(与快速编辑 / 信息浮层同一套锚点几何)。 + * + * 占位被拖走或删掉时这里立刻失去锚点:浮层跟着卡走,卡没了浮层也不再渲染。 + */ + const resourceGenerationPanelStyle = resourceGenerationPanelPlaceholder + ? resolveResourceCanvasGenerationPanelStyle({ + placeholder: resourceGenerationPanelPlaceholder, + viewport: resourceCanvasSceneViewportRef.current, + canvasSize: resourceBookSceneSize, + }) + : null; + + /** + * 收起生成浮层。 + * + * 关闭**只是把这块 UI 收起来**:不取消后台任务、不删占位、不丢草稿身份。已提交的任务继续跑完 + * 并把结果登记进项目,点占位卡随时能再挂回同一块面板。 + */ + const closeResourceGenerationFloatingPanel = useCallback( + (draftId: string) => { + setResourceGenerationDraft((current) => + current?.draftId === draftId ? null : current, + ); + setResourceAssetGenerationPanel((current) => + current?.draftId === draftId ? null : current, + ); + }, [], ); + /** + * 删除占位卡。 + * + * 只影响展示:已提交的任务照旧在账本里跑完、结果照旧登记进项目(只是不再有占位位置可接管, + * 新卡按自动坐标落位并被定位一次)。未提交的草稿随占位一起消失——它本来就什么都没发出去。 + */ + const removeResourceGenerationPlaceholderCard = useCallback( + (draftId: string, assetName: string, submitted: boolean) => { + resourceGenerationPlaceholders.remove(draftId); + closeResourceGenerationFloatingPanel(draftId); + setResourceAssetGenerationPanelReopen((current) => + current?.draftId === draftId ? null : current, + ); + setResourceWorkbenchNotice( + submitted + ? `已移除占位;「${assetName}」仍在后台生成,完成后会自动落卡` + : '', + ); + }, + [closeResourceGenerationFloatingPanel, resourceGenerationPlaceholders], + ); + + /** + * 点占位卡:开 / 收挂在它下面的生成浮层。 + * + * - 浮层开着 → 收起(任务不受影响)。 + * - 已提交 → 不重开表单(这次生成已经在跑,改参数没有意义),转而打开「生成任务」侧栏看进度。 + * - 草稿 / 失败 → 重新挂上同一块面板;失败的重试带回上一次的提示词与参考图。 + */ + const toggleResourceGenerationPlaceholderPanel = useCallback( + (placeholder: ResourceCanvasGenerationPlaceholder) => { + if (resourceGenerationPanelDraftId === placeholder.draftId) { + closeResourceGenerationFloatingPanel(placeholder.draftId); + return; + } + if (placeholder.status === 'submitted') { + setResourceAssetGenerationTasksPanelOpen(true); + setResourceWorkbenchNotice( + `「${placeholder.assetName}」正在生成,进度见「生成任务」`, + ); + return; + } + const retryTask = + resourceAssetGenerationTasksRef.current.find( + (task) => task.draftId === placeholder.draftId, + ) ?? null; + if (placeholder.panel.route === 'audio') { + setResourceGenerationDraft({ + initialKind: placeholder.panel.initialKind, + kinds: placeholder.panel.kinds, + draftId: placeholder.draftId, + }); + return; + } + if (retryTask) { + setResourceAssetGenerationPanelReopen({ + actionId: placeholder.actionId, + draftId: placeholder.draftId, + draft: { + prompt: retryTask.prompt, + assetName: retryTask.assetName, + aspectRatio: retryTask.aspectRatio, + imageSize: retryTask.imageSize, + // 参考图按资产 ID 还原成引用:只认还在清单里的那些,缺的交给提交前的陈旧引用判据报出来。 + references: retryTask.referenceAssetIds + .map((assetId) => { + const asset = manifest.assets.find( + (candidate) => candidate.id === assetId, + ); + return asset + ? resourceReferenceFromAsset(asset, 'asset-picker') + : null; + }) + .filter( + (reference): reference is ResourceReference => + reference !== null, + ), + }, + error: placeholder.error ?? '生成素材失败', + }); + } else { + setResourceAssetGenerationPanelReopen(null); + } + setResourceAssetGenerationPanel({ + action: placeholder.panel.action, + draftId: placeholder.draftId, + }); + }, + [ + closeResourceGenerationFloatingPanel, + manifest.assets, + resourceGenerationPanelDraftId, + ], + ); + /** * 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样 * 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在 @@ -8175,10 +8522,54 @@ export default function ProjectDevelopmentView({ mainViewport={resourceBookMainViewport} renderCard={renderResourceBookCard} worldOverlay={ - + <> + + {/* + 生成占位卡:活在**栏目页**的世界坐标系里(与同栏目资源卡同一坐标系), + 所以拖动、缩放、视口平移都跟画布一起动。占位不进资源投影、不参与框选, + 指针事件自己接管(见 `useResourceCanvasGenerationPlaceholders`)。 + */} + {resourceBookView === 'child' + ? resourceGenerationPlaceholders.placeholders + .filter( + (placeholder) => + placeholder.category === + resourceCanvasBottomToolbarCategory, + ) + .map((placeholder) => ( + + toggleResourceGenerationPlaceholderPanel( + placeholder, + ) + } + onRemove={() => + removeResourceGenerationPlaceholderCard( + placeholder.draftId, + placeholder.assetName, + placeholder.taskId !== null, + ) + } + /> + )) + : null} + } overlay={ resourceBookView === 'main' ? null : ( @@ -8523,6 +8914,88 @@ export default function ProjectDevelopmentView({ onClose={() => setResourceInfoPanelOpen(false)} /> ) : null} + {/* + 生成浮层:**挂在占位卡下沿的独立浮层**,不是模态。 + + 模态(`ThemedModal` + 焦点陷阱)会把 `@` 引用选择器挡在陷阱之外—— + 选择器 portal 到 body,候选项点了不生效(真实验收复现过)。浮层形态 + 既解决了这条阻断,也天然满足「关闭浮层不等于取消后台任务」。 + */} + {resourceGenerationDraft && + resourceGenerationPanelPlaceholder?.panel.route === + 'audio' && + resourceGenerationPanelStyle ? ( + + submitResourceCanvasGeneration( + input, + resourceGenerationDraft.draftId, + ) + } + onClose={() => + closeResourceGenerationFloatingPanel( + resourceGenerationDraft.draftId, + ) + } + /> + ) : null} + {resourceAssetGenerationPanel && + resourceGenerationPanelPlaceholder?.panel.route === + 'asset' && + resourceGenerationPanelStyle ? ( + + submitResourceAssetGeneration( + resourceAssetGenerationPanel.action, + input, + resourceAssetGenerationPanel.draftId, + ) + } + onClose={() => { + setResourceAssetGenerationPanelReopen((current) => + current?.draftId === + resourceAssetGenerationPanel.draftId + ? null + : current, + ); + closeResourceGenerationFloatingPanel( + resourceAssetGenerationPanel.draftId, + ); + }} + /> + ) : null} ) } @@ -9060,50 +9533,6 @@ export default function ProjectDevelopmentView({ ) : null} - {resourceGenerationDraft ? ( - setResourceGenerationDraft(null)} - /> - ) : null} - {resourceAssetGenerationAction ? ( - - submitResourceAssetGeneration(resourceAssetGenerationAction, input) - } - onClose={() => { - setResourceAssetGenerationPanelReopen(null); - setResourceAssetGenerationAction(null); - }} - /> - ) : null} {/* 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ccb13311f..3e061dba9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7,6 +7,10 @@ import type { } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { ProjectSupervisorView } from '../../src/features/project-workspace/ProjectSupervisorView'; import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; +import { + generationPromptText, + typeGenerationPrompt, +} from '../resourceGenerationPromptTestUtils'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -346,9 +350,9 @@ async function submitBottomToolbarPanel( fireEvent.click(screen.getByRole('button', { name: label })); } const panel = await screen.findByRole('dialog', { name: label }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: options.prompt }, - }); + // 提示词输入区是与聊天同一份 `@` 引用输入区(Lexical):jsdom 没有可用 Selection, + // 浏览器输入事件不会落字,只能走编辑器更新(见 resourceGenerationPromptTestUtils)。 + await typeGenerationPrompt(panel, options.prompt); fireEvent.click(within(panel).getByRole('button', { name: label })); // 成功路径由宿主卸载面板:等它消失,下一个入口才不会撞上残留的浮层。 await waitFor(() => @@ -11816,9 +11820,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(firstPanel).getByLabelText('素材名称'), { target: { value: '第一条设计图' }, }); - fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), { - target: { value: '第一条界面' }, - }); + await typeGenerationPrompt(firstPanel, '第一条界面'); fireEvent.click( within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -11843,9 +11845,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(secondPanel).getByLabelText('素材名称'), { target: { value: '第二条设计图' }, }); - fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { - target: { value: '第二条界面' }, - }); + await typeGenerationPrompt(secondPanel, '第二条界面'); fireEvent.click( within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -12403,9 +12403,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(panel).getByLabelText('素材名称'), { target: { value: '待提交设计图' }, }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: '主界面与背包页' }, - }); + await typeGenerationPrompt(panel, '主界面与背包页'); return panel; } @@ -12452,10 +12450,9 @@ export function registerProjectAgentStatusTests() { '项目权限策略拒绝执行:canvas.asset_generate', ), ); - expect( - (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) - .value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(reopened)).toBe('主界面与背包页'), + ); expect( (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, ).toBe('待提交设计图'); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 1b7c0a8d0..0a1ba40ca 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -13,6 +13,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; afterEach(() => { cleanup(); @@ -50,7 +54,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); expect( @@ -84,7 +88,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); await user.click( screen.getByRole('button', { name: '关闭生成 UI 设计图' }), @@ -112,6 +116,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 assetName: 'AI 生成 UI 设计图', aspectRatio: '16:9', imageSize: '1K', + references: [], }} error="生成素材失败:远端拒绝" onSubmit={onSubmit} @@ -122,9 +127,9 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 expect(screen.getByRole('alert').textContent).toContain( '生成素材失败:远端拒绝', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(document.body)).toBe('主界面与背包页'), + ); await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); expect(onSubmit).toHaveBeenCalledTimes(1); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index de46eefd5..8cdfb6247 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -83,7 +83,7 @@ describe('生成任务模型', () => { expect(task.referenceAssetIds).toEqual([]); }); - test('参考图资产 ID 随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { + test('参考图资产 ID 去重后随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { const task = createResourceCanvasAssetGenerationTask({ taskId: 'task-ref', action: uiPrototypeAction, @@ -91,12 +91,12 @@ describe('生成任务模型', () => { assetName: 'UI', aspectRatio: '16:9', imageSize: '1K', - referenceAssetIds: ['asset-a', 'asset-b', 'asset-a'], + referenceAssetIds: ['asset-a', ' asset-b ', 'asset-a', ''], outputPath: null, projectId: 'project-1', nowMillis: 10, }); - expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b', 'asset-a']); + expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b']); const restored = applyLocalProjectAssetGenerationRecords( [], diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index c70900b2c..22ce65ddb 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -16,6 +16,7 @@ import { resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIssue, resourceCanvasAssetGenerationUserReferenceLimit, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; @@ -137,12 +138,46 @@ describe('参考图模型', () => { asset('asset-image', 'image/png', 'assets/a.png'), asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), + // 原生按 `image::load_from_memory` 解码,SVG 读不出来:进了候选就是一次必然失败的付费提交。 + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-svg-alias', 'image/svg', 'assets/b.svg'), asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), asset('asset-remote-only', 'image/png', ' '), ]); expect(candidates.map((item) => item.id)).toEqual(['asset-image']); }); + test('陈旧引用判据把矢量图与缺失文件分开报,不做静默过滤', () => { + const assets = [ + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-no-file', 'image/png', ' '), + ]; + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-svg', '矢量图')], + assets, + }), + ).toContain('矢量图'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-no-file', '没落盘')], + assets, + }), + ).toContain('没有本地文件'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-missing', '已删除')], + assets, + }), + ).toContain('已不在当前项目'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-image', '正常图片')], + assets: [asset('asset-image', 'image/webp', 'assets/a.webp')], + }), + ).toBeNull(); + }); + test('参考 ID 只取资源引用、按选择顺序去重', () => { expect( resourceCanvasAssetGenerationReferenceIds([ diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index c228db240..8f8a3c477 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -26,6 +26,11 @@ import { resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; afterEach(cleanup); @@ -367,9 +372,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { screen.queryByRole('button', { name: '图标规范比例 1:1' }), ).toBeNull(); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '像素月光厨房的统一视觉规范' }, - }); + await typeGenerationPrompt(panel, '像素月光厨房的统一视觉规范'); fireEvent.click(screen.getByRole('button', { name: '图标规范' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -378,6 +381,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: '图标规范', aspectRatio: '1:1', imageSize: '1K', + references: [], }), ); }); @@ -402,9 +406,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { fireEvent.click( screen.getByRole('button', { name: '生成 UI 设计图尺寸 2K' }), ); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '横屏单屏界面' }, - }); + await typeGenerationPrompt(document.body, '横屏单屏界面'); fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -413,11 +415,12 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: 'AI 生成 UI 设计图', aspectRatio: '9:16', imageSize: '2K', + references: [], }), ); }); - test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', async () => { const onSubmit = vi.fn(); const onClose = vi.fn(); const action = assetActionOf('character', '生成角色形象'); @@ -431,9 +434,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { const submit = screen.getByRole('button', { name: '生成角色形象' }); expect((submit as HTMLButtonElement).disabled).toBe(true); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '披风猫骑士' }, - }); + await typeGenerationPrompt(document.body, '披风猫骑士'); fireEvent.click(submit); // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 expect(onSubmit).toHaveBeenCalledTimes(1); @@ -446,6 +447,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: string; aspectRatio: string; imageSize: string; + references: ChatReference[]; }; render( { assetName: first.assetName, aspectRatio: first.aspectRatio, imageSize: first.imageSize, + references: first.references, }} error="图片比例不受支持:4:3" onSubmit={onSubmit} @@ -464,9 +467,10 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('披风猫骑士'); + // 草稿会重新灌回 `@` 引用输入区;它由编辑器异步落到 DOM,等一次。 + await waitFor(() => + expect(generationPromptText(document.body)).toContain('披风猫骑士'), + ); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx new file mode 100644 index 000000000..7e852ee5a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function asset( + id: string, + label: string, + mediaType = 'image/png', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'image', + mediaType, + localPath: `assets/${label}.png`, + source: { kind: 'canvas' }, + }; +} + +/** 取面板里的引用输入区(含 `@` 触发器按钮),把操作局限在它自己身上。 */ +function referenceInputScope(ariaLabel: string) { + const root = screen.getByLabelText(ariaLabel).closest('.resource-reference-input'); + if (!root) { + throw new Error(`资源引用输入区不存在:${ariaLabel}`); + } + return within(root as HTMLElement); +} + +describe('生成浮层里的引用选择(真实组件,不做 props mock)', () => { + test('浮层不是模态,真实点击候选就能选中并随提交带走引用', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [ + asset('asset-a', '素材-a'), + asset('asset-b', '素材-b'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + /* + 浮层形态不是模态:没有全屏遮罩、没有 aria-modal。`ThemedModal` 的焦点陷阱正是 P1 的成因—— + 引用选择器 portal 到 body,被陷阱挡在外面时候选项点了不生效(真实浏览器复现过)。 + */ + expect(panel.getAttribute('aria-modal')).toBeNull(); + expect(document.querySelector('[aria-modal="true"]')).toBeNull(); + expect(document.querySelector('.fixed.inset-0')).toBeNull(); + + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click( + within(picker).getByRole('option', { name: /素材-a/ }), + ); + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(); + const insert = within(picker).getByRole('button', { name: '插入引用' }); + expect((insert as HTMLButtonElement).disabled).toBe(false); + await user.click(insert); + + // 选中的引用进了面板:计数可见、提交时随载荷带走。 + await waitFor(() => + expect(within(panel).getByText('参考图 1/5')).not.toBeNull(), + ); + await user.click(within(panel).getByRole('button', { name: '生成图片' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + const submitted = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + references: { resourceId: string }[]; + }; + expect(submitted.references.map((reference) => reference.resourceId)).toEqual( + ['asset-a'], + ); + expect(submitted.prompt).toContain('画一只猫'); + }); + + test('搜索能收窄候选,键盘也能完成选择', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [ + asset('asset-a', '素材-a'), + asset('asset-b', '素材-b'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + expect(within(picker).getAllByRole('option')).toHaveLength(2); + + // 搜索收窄:只剩「素材-b」这一条候选。 + await user.clear(screen.getByLabelText('搜索全部画布素材')); + await user.type(screen.getByLabelText('搜索全部画布素材'), '素材-b'); + await waitFor(() => + expect(within(picker).getAllByRole('option')).toHaveLength(1), + ); + + // 键盘路径:Tab 进候选列表,Enter 选中(不依赖鼠标点击)。 + const option = within(picker).getByRole('option', { name: /素材-b/ }); + option.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx new file mode 100644 index 000000000..bbae19c22 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等), +// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。 +import { act, cleanup, fireEvent, render, screen } from './appSurface/harness'; + +import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { ResourceCanvasGenerationPlaceholderCardView } from '../src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView'; +import { + bindResourceCanvasGenerationPlaceholderTask, + failResourceCanvasGenerationPlaceholder, + moveResourceCanvasGenerationPlaceholder, + placeResourceCanvasGenerationPlaceholder, + removeResourceCanvasGenerationPlaceholder, + resolveResourceCanvasGenerationPanelStyle, + resourceCanvasGenerationPlaceholderByDraftId, + resourceCanvasGenerationPlaceholderByTaskId, + resourceCanvasGenerationPlaceholderSize, + resourceCanvasGenerationPlaceholdersForProject, + type ResourceCanvasGenerationPlaceholder, +} from '../src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; +import { useResourceCanvasGenerationPlaceholders } from '../src/features/resource-canvas/useResourceCanvasGenerationPlaceholders'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function placeholderFixture( + overrides: Partial = {}, +): ResourceCanvasGenerationPlaceholder { + const size = resourceCanvasGenerationPlaceholderSize(); + return { + draftId: 'draft-1', + projectId: 'project-1', + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + x: 0, + y: 0, + width: size.width, + height: size.height, + taskId: null, + status: 'draft', + error: null, + ...overrides, + }; +} + +describe('生成占位模型', () => { + test('空栏目落在原点,已有内容时排到最下面一行之下(占位之间也不重叠)', () => { + const size = resourceCanvasGenerationPlaceholderSize(); + expect(placeResourceCanvasGenerationPlaceholder({ occupied: [] })).toEqual({ + x: 0, + y: 0, + }); + const first = placeResourceCanvasGenerationPlaceholder({ + occupied: [{ x: 0, y: 0, width: size.width, height: size.height }], + }); + expect(first.x).toBe(0); + expect(first.y).toBe(size.height + 16); + const second = placeResourceCanvasGenerationPlaceholder({ + occupied: [ + { x: 0, y: 0, width: size.width, height: size.height }, + { x: 0, y: first.y, width: size.width, height: size.height }, + ], + }); + expect(second.y).toBe(first.y + size.height + 16); + }); + + test('拖动只改坐标:有限数取整、越界收到坐标域、非法值保持原位', () => { + const base = placeholderFixture({ x: 10, y: 20 }); + expect(moveResourceCanvasGenerationPlaceholder(base, 33.6, -7.4)).toMatchObject( + { x: 34, y: -7 }, + ); + expect( + moveResourceCanvasGenerationPlaceholder(base, 1e9, -1e9), + ).toMatchObject({ + x: GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + y: -GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + }); + expect( + moveResourceCanvasGenerationPlaceholder(base, Number.NaN, Number.NaN), + ).toMatchObject({ x: 10, y: 20 }); + }); + + test('提交绑定任务、失败保留占位、删除只移除展示', () => { + const bound = bindResourceCanvasGenerationPlaceholderTask( + placeholderFixture(), + 'task-1', + ); + expect(bound).toMatchObject({ status: 'submitted', taskId: 'task-1' }); + const failed = failResourceCanvasGenerationPlaceholder(bound, '远端拒绝'); + expect(failed).toMatchObject({ + status: 'failed', + taskId: 'task-1', + error: '远端拒绝', + }); + // 失败不删占位:同一份输入与位置还要用来重试。 + expect(resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-1')).toBe( + failed, + ); + expect( + resourceCanvasGenerationPlaceholderByDraftId([failed], 'draft-1'), + ).toBe(failed); + expect(resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-2')).toBe( + null, + ); + expect(removeResourceCanvasGenerationPlaceholder([failed], 'draft-1')).toEqual( + [], + ); + }); + + test('归属按项目收口:切项目只留当前项目的占位', () => { + const placeholders = [ + placeholderFixture({ draftId: 'draft-a', projectId: 'project-1' }), + placeholderFixture({ draftId: 'draft-b', projectId: 'project-2' }), + ]; + expect( + resourceCanvasGenerationPlaceholdersForProject( + placeholders, + 'project-2', + ).map((item) => item.draftId), + ).toEqual(['draft-b']); + }); + + test('浮层锚点贴着占位下沿居中(与快速编辑同一条几何)', () => { + const style = resolveResourceCanvasGenerationPanelStyle({ + placeholder: placeholderFixture({ x: 100, y: 200 }), + viewport: { x: 0, y: 0, scale: 2 }, + canvasSize: { width: 800, height: 600 }, + }); + const size = resourceCanvasGenerationPlaceholderSize(); + expect(style?.left).toBe((100 + size.width / 2) * 2); + expect(style?.top).toBeGreaterThan((200 + size.height) * 2); + }); +}); + +describe('占位卡组件', () => { + test('呈现名称与状态,点卡开合浮层、点删除只删除自己', () => { + const onTogglePanel = vi.fn(); + const onRemove = vi.fn(); + render( + undefined} + onPointerMove={() => undefined} + onPointerUp={() => undefined} + onPointerCancel={() => undefined} + onTogglePanel={onTogglePanel} + onRemove={onRemove} + />, + ); + + const card = screen.getByRole('button', { + name: 'AI 生成图片(生成失败)', + }); + expect(card.getAttribute('data-resource-canvas-generation-placeholder')).toBe( + 'draft-1', + ); + fireEvent.click(card); + expect(onTogglePanel).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: '删除占位 AI 生成图片' })); + expect(onRemove).toHaveBeenCalledTimes(1); + // 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。 + expect(onTogglePanel).toHaveBeenCalledTimes(1); + }); +}); + +describe('占位宿主 Hook', () => { + function renderPlaceholderHook(projectId = 'project-1') { + return renderHook( + ({ currentProjectId }: { currentProjectId: string }) => + useResourceCanvasGenerationPlaceholders({ + projectId: currentProjectId, + resolvePlacement: ({ placeholders: sameSection }) => ({ + x: 0, + y: sameSection.length * 100, + }), + viewportScale: () => 2, + }), + { initialProps: { currentProjectId: projectId } }, + ); + } + + test('创建占位、绑定任务与失败收口', () => { + const hook = renderPlaceholderHook(); + let draftId = ''; + act(() => { + const created = hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + draftId = created.draftId; + }); + expect(hook.result.current.placeholders).toHaveLength(1); + expect(hook.result.current.placeholders[0]).toMatchObject({ + projectId: 'project-1', + category: 'scene', + status: 'draft', + taskId: null, + }); + + act(() => hook.result.current.bindTask(draftId, 'task-1')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'submitted', + taskId: 'task-1', + }); + + act(() => hook.result.current.failTask('task-1', '远端拒绝')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'failed', + error: '远端拒绝', + }); + + act(() => hook.result.current.remove(draftId)); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('切项目清掉上一份会话的占位', () => { + const hook = renderPlaceholderHook(); + act(() => { + hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + }); + expect(hook.result.current.placeholders).toHaveLength(1); + + act(() => hook.rerender({ currentProjectId: 'project-2' })); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => { + function Harness() { + const [created, setCreated] = useState(false); + const placeholders = useResourceCanvasGenerationPlaceholders({ + projectId: 'project-1', + resolvePlacement: () => ({ x: 10, y: 20 }), + viewportScale: () => 2, + }); + useEffect(() => { + if (created) { + return; + } + placeholders.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + setCreated(true); + }, [created, placeholders]); + const placeholder = placeholders.placeholders[0]; + if (!placeholder) { + return null; + } + return ( + undefined} + onRemove={() => undefined} + /> + ); + } + + render(); + const card = screen.getByRole('button', { + name: 'AI 生成图片(待提交)', + }); + expect(card.style.left).toBe('10px'); + expect(card.style.top).toBe('20px'); + + fireEvent.pointerDown(card, { + pointerId: 1, + button: 0, + isPrimary: true, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 1, + clientX: 140, + clientY: 130, + }); + // 视口缩放 2:屏幕 40 / 30 px 对应画布 20 / 15 px。 + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + fireEvent.pointerUp(card, { pointerId: 1, clientX: 140, clientY: 130 }); + + // 取消手势:回到拖动前坐标,不留下半截位置。 + fireEvent.pointerDown(card, { + pointerId: 2, + button: 0, + isPrimary: true, + clientX: 200, + clientY: 200, + }); + fireEvent.pointerMove(card, { + pointerId: 2, + clientX: 260, + clientY: 260, + }); + expect(card.style.left).toBe('60px'); + fireEvent.pointerCancel(card, { pointerId: 2 }); + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts new file mode 100644 index 000000000..950fca4e5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts @@ -0,0 +1,40 @@ +import { act, within } from '@testing-library/react'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + type LexicalEditor, +} from 'lexical'; + +/** + * 往生成面板的提示词输入区写一段文本。 + * + * 提示词输入区与聊天、资源快速编辑共用同一个 `@` 引用输入区(Lexical contenteditable)。 + * jsdom 没有可用的 DOM Selection,Lexical 因此会忽略浏览器输入事件——`userEvent.type` 与 + * `beforeinput` 都不会落字,`fireEvent.change` 更不适用。所以这条链路只能在编辑器实例上做 + * 等价更新:仍然走 Lexical 的 `update()` → `OnChangePlugin` → 面板状态,断言的是面板真正收到 + * 的提示词与引用。浏览器里的真实输入路径由引用输入区自己的测试与实机验收覆盖。 + */ +export async function typeGenerationPrompt(scope: HTMLElement, text: string) { + const element = within(scope).getByLabelText('生成提示词') as HTMLElement & { + __lexicalEditor?: LexicalEditor; + }; + const editor = element.__lexicalEditor; + if (!editor) { + throw new Error('生成提示词输入区不是 Lexical 编辑器'); + } + await act(async () => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + root.append(paragraph); + }); + }); +} + +/** 读回提示词输入区当前呈现的文本(contenteditable 没有 `value`)。 */ +export function generationPromptText(scope: HTMLElement) { + return within(scope).getByLabelText('生成提示词').textContent ?? ''; +} From 1c5ff8f85249ac41dc738c7a4b704b6838cd2993 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:25:26 +0800 Subject: [PATCH 23/68] =?UTF-8?q?=E5=9B=9E=E5=A1=AB=20AGC=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=B8=A0=E9=81=93=E9=A6=96=E6=AC=A1=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录 Genarrative-Agc-Windows-Build #68 成功发布 dev-win 渠道 0.1.48 与迁移桥 - 记录签名对象与渠道清单逐字一致、安装包尺寸与 SHA-256 与迁移桥登记一致 - 待执行项仅保留客户端真实升级闭环与签名失败拒绝安装 --- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index a20a900f1..61a738e77 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -98,22 +98,24 @@ 已获得的证据: -| 条款 | 验收方式 | 证据 | -| ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------- | -| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | -| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | -| 缺签名时失败关闭 | 同上 | 通过 | -| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | -| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | +| 条款 | 验收方式 | 证据 | +| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | +| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| 缺签名时失败关闭 | 同上 | 通过 | +| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | +| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | +| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 | +| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) | +| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | +| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | 待执行证据(首次渠道发布后回填): -| 条款 | 验收方式 | 证据 | -| ---------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| 清单与对象布局符合渠道约定 | `ossutil ls oss://agc-dev/agc/dev-win//`;`ossutil cat .../dev-win/latest.json` | 待执行:URL 指向已存在安装包,签名与 `.sig` 内容一致 | -| 旧协议迁移桥 | `ossutil cat oss://agc-dev/agc/latest.json` | 待执行:`sha256` / `size` 与同一安装包匹配 | -| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | -| 签名校验失败拒绝安装 | 渠道清单签名与实际安装包不匹配时的表现 | 待执行(需要真实渠道清单) | +| 条款 | 验收方式 | 证据 | +| ---------------------------- | --------------------------------------------------------------------- | ------ | +| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | ## 未决问题与决策 From d222aad2ec2c1e071fe847e2ad42e45ea147fa5e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:24:33 +0800 Subject: [PATCH 24/68] =?UTF-8?q?=E5=8F=82=E8=80=83=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8F=90=E4=BA=A4=E5=89=8D=E9=A2=84=E6=A3=80?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=8F=82=E8=80=83=E9=93=BE=E8=B7=AF=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考解析前增加纯本地预检:清单归属、受控路径与文件、媒体类型、可解码位图全部通过后才允许产生任何上传副作用,避免第一张参考已上传、第二张坏图才失败 明确拒绝 SVG 等矢量参考,媒体类型或文件扩展名任一命中即拒,本次不做矢量转换,坏图同样在本地失败关闭 asset.upload 门禁提前到预检阶段,显式拒绝该命令的项目连第一个上传凭证都不会签发 预检与实际上传共用同一份本地判据 helper,不新建平行流程,上传侧仍按同一判据重新读取与解码 补参考合同与恢复的账本用例:icon-spec 带用户参考、game-background 规范图加用户参考、图集超限、空白引用写入失败 补提交侧用例:拒绝路径、未登记、非图片、缺失文件、超限、SVG 与坏图,并断言零上传凭证与零生成 POST,反向验证同一张合格参考单独提交确实会签发凭证 补 asset.upload 拒绝用例,以及清单校验与 Direct 身份校验接受用户参考、拒绝用户参考顶替规范引用的覆盖 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 72 +++ .../src/agent/generation/canvas_generation.rs | 475 ++++++++++++++++-- .../src-tauri/src/tests/project.rs | 455 +++++++++++++++++ 3 files changed, 952 insertions(+), 50 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 87e4b3174..7a0b84b13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -9808,6 +9808,78 @@ mod tests { ); } + #[test] + fn direct_taonier_art_package_accepts_manifest_user_references() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + assert!(direct_taonier_art_package_is_valid(root.path())); + + // 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。 + mutate_manifest_at(root.path(), |manifest| { + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "user-reference-2".to_string(), + ]; + let background = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH) + .expect("background asset"); + background + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("apply user references to the art base"); + assert!( + direct_taonier_art_package_is_valid(root.path()), + "user references must not invalidate the art package" + ); + + // 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("add an extra spritesheet reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "art spritesheet must reject extra user references" + ); + + // 用户参考不能顶替图集的规范前置。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + Ok(()) + }) + .expect("replace the spritesheet canonical reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a user reference must not replace the art spritesheet canonical spec" + ); + } + #[test] fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index a544ba9db..b298c0829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1720,6 +1720,24 @@ async fn canonical_art_spec_reference_at( access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { + let (manifest_project_id, source) = canonical_art_spec_manifest_entry_at(root)?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest_project_id, + expected_canvas_project_id, + &source, + ) + .await +} + +/// 当前项目已登记的规范图清单条目(`assets/art-spec.png` 且 `icon-spec`)。 +/// +/// 提交前预检与实际上传共用这一份归属判据:路径、远端 ID 或其它项目素材都不能冒充规范图。 +fn canonical_art_spec_manifest_entry_at( + root: &Path, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets @@ -1733,50 +1751,75 @@ async fn canonical_art_spec_reference_at( .ok_or_else(|| { "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() - })?; - upload_manifest_asset_remote_reference_at( - root, - client, - access, - &manifest.project_id, - expected_canvas_project_id, - source, - ) - .await + })? + .clone(); + Ok((manifest.project_id, source)) } -/// 用户参考素材(当前项目 manifest `assets[].id`)解析当前账号的远端资源 ID。 +/// 用户参考素材(当前项目 manifest `assets[].id`)的清单归属解析。 /// -/// 只接受**当前项目**清单里的图片素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 只接受**当前项目**清单里的素材:路径、远端 resourceId、其它项目的素材都不在清单里, /// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 -async fn manifest_asset_remote_reference_at( +fn manifest_asset_reference_entry_at( root: &Path, - client: &reqwest::Client, - access: &ExternalEditorBindingAccess<'_>, - expected_canvas_project_id: &str, asset_id: &str, -) -> Result { +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets .iter() .find(|asset| asset.id == asset_id) - .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))?; + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 参考素材的**纯本地**校验与读取:清单身份由调用方先证明,这里只管受控路径 → 文件存在 → +/// 媒体类型 → 可解码位图。 +/// +/// 不做任何远端调用;提交前预检与实际上传读同一份判据。SVG 等矢量格式必须在**任何上传之前** +/// 明确拒绝:上游 `image/*` 筛选会放进 SVG,而位图解码器必定失败;本次不做隐式转换,也不允许 +/// 「第一张参考已上传、第二张坏图才失败」的半完成副作用。 +fn read_validated_platform_art_reference_at( + root: &Path, + source: &GameCreationAppAssetManifestEntry, +) -> Result<(Vec, image::DynamicImage), String> { if !source.media_type.starts_with("image/") { return Err(format!( "参考素材必须是图片,不能引用 {}:{}", - source.media_type, asset_id + source.media_type, source.id )); } - upload_manifest_asset_remote_reference_at( - root, - client, - access, - &manifest.project_id, - expected_canvas_project_id, - source, - ) - .await + if platform_art_reference_source_is_vector(source) { + return Err(format!( + "参考素材不支持 SVG 等矢量格式,请改用 PNG/JPEG 位图:{}", + source.id + )); + } + let source_path = resolve_local_project_path(root, &source.local_path)?; + if !source_path.is_file() { + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); + } + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + Ok((bytes, decoded)) +} + +/// SVG 等矢量格式:媒体类型或文件扩展名任一命中都算矢量,避免只靠声明类型漏判。 +fn platform_art_reference_source_is_vector(source: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = source.media_type.trim().to_ascii_lowercase(); + let media_type = media_type.split(';').next().unwrap_or_default().trim(); + if matches!(media_type, "image/svg+xml" | "image/svg") { + return true; + } + Path::new(source.local_path.trim()) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "svg" | "svgz")) } /// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 @@ -1796,16 +1839,8 @@ async fn upload_manifest_asset_remote_reference_at( // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 enforce_project_permission_policy(root, "asset.upload")?; let file_name = platform_art_reference_upload_file_name(source); - let source_path = resolve_local_project_path(root, &source.local_path)?; - if !source_path.is_file() { - return Err(format!( - "参考素材 {} 不存在;请重新登记后再引用", - source.local_path - )); - } - let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; - let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + // 与提交前预检共用同一份本地判据:受控路径、文件存在、媒体类型(含 SVG 拒绝)与可解码性。 + let (bytes, decoded) = read_validated_platform_art_reference_at(root, source)?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -2050,26 +2085,48 @@ async fn resolve_platform_art_generation_references_at( &options.asset_kind, &options.reference_asset_ids, )?; - let canonical = - if platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind) { - Some( - canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) - .await?, - ) - } else { - None - }; + let requires_canonical = + platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, + // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 + if requires_canonical || !user_reference_asset_ids.is_empty() { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` + // 门禁,显式拒绝该命令的项目在本地就失败关闭,连第一个上传凭证都不会签发。 + enforce_project_permission_policy(root, "asset.upload")?; + } + let canonical_source = requires_canonical + .then(|| canonical_art_spec_manifest_entry_at(root)) + .transpose()?; + if let Some((_, source)) = canonical_source.as_ref() { + let _ = read_validated_platform_art_reference_at(root, source)?; + } + let mut user_reference_sources = Vec::with_capacity(user_reference_asset_ids.len()); + for asset_id in &user_reference_asset_ids { + let (manifest_project_id, source) = manifest_asset_reference_entry_at(root, asset_id)?; + let _ = read_validated_platform_art_reference_at(root, &source)?; + user_reference_sources.push((manifest_project_id, source)); + } + // 预检全部通过后才允许产生远端副作用。 + let canonical = if requires_canonical { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; let mut ordered = Vec::new(); if let Some(reference) = canonical.as_ref() { ordered.push(reference.clone()); } - for asset_id in &user_reference_asset_ids { - let reference = manifest_asset_remote_reference_at( + for (manifest_project_id, source) in &user_reference_sources { + let reference = upload_manifest_asset_remote_reference_at( root, client, access, + manifest_project_id, expected_canvas_project_id, - asset_id, + source, ) .await?; if !ordered.iter().any(|existing| existing == &reference) { @@ -12616,6 +12673,324 @@ mod canvas_generation_tests { ); } + /// 参考素材 id 入参只按当前项目清单形状收口:路径、远端资源 ID、控制字符与超限都在这里拒绝。 + #[test] + fn reference_asset_ids_are_normalized_and_rejected_before_any_remote_call() { + let ids = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + // 去重保持给出顺序,空白项直接丢弃。 + assert_eq!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) + .expect("normalize icon-spec references"), + ids(&["b", "a"]) + ); + // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + "hero\u{7}", + &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), + ] { + assert!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + "{rejected} 不能被当成参考素材 id" + ); + } + // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 + assert_eq!( + normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e"]) + ) + .expect("five references without a canonical spec") + .len(), + 5 + ); + assert!(normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e", "f"]) + ) + .is_err()); + assert_eq!( + normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) + .expect("four user references with a canonical spec") + .len(), + 4 + ); + assert!(normalize_platform_art_reference_asset_ids( + "ui-prototype", + &ids(&["a", "b", "c", "d", "e"]) + ) + .is_err()); + // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() + ); + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) + .expect("spritesheet without user references") + .is_empty() + ); + } + + /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 + #[test] + fn reference_contract_matches_the_submission_limits_per_kind() { + let references = |count: usize| { + (0..count) + .map(|index| format!("reference-{index}")) + .collect::>() + }; + // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 + assert!(platform_art_runtime_references_match_request_contract( + &[], + "icon-spec" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "icon-spec" + )); + // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 + for kind in ["ui-prototype", "game-background"] { + assert!( + !platform_art_runtime_references_match_request_contract(&[], kind), + "{kind} 必须有规范图前置" + ); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + kind + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + kind + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + kind + )); + } + // 图集:恰好一项,多一项都不算同一份请求合同。 + assert!(!platform_art_runtime_references_match_request_contract( + &[], + "art-spritesheet" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + "art-spritesheet" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(2), + "art-spritesheet" + )); + // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &[" ".to_string()], + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(1), + "unknown-kind" + )); + } + + /// 保留账本的恢复校验必须接受与提交同一套参考合同。 + /// + /// 旧实现把 `icon-spec` 写死成「引用必须为空」、把有规范前置的生成写死成「恰好 1 项」, + /// 带用户参考的合法账本会被判成身份不符而恢复失败。这里直接写真实账本再读回校验。 + #[test] + fn retained_stage_recovery_accepts_the_same_reference_contract_as_submission() { + fn write_retained_stage_result( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> Result { + let context = PlatformArtGenerationRuntimeContext { + agent_id: "manual-canvas-asset-generate".to_string(), + task_id: "retained-reference-contract-task".to_string(), + session_id: "retained-reference-contract-session".to_string(), + run_id: run_id.to_string(), + source: "test".to_string(), + action_id: format!("retained-reference-contract-{run_id}"), + action_fingerprint: format!("retained-reference-contract-v1:{run_id}"), + }; + let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let frozen_platform_session = frozen_platform_session + .ok_or_else(|| "保留账本测试必须使用平台账号".to_string())?; + let access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session)?; + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "retained-reference-contract-canvas", + "保留账本参考合同", + &request_body, + &access, + )?; + if !created { + return Err("保留账本测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(context) + } + + fn write_retained_stage( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> PlatformArtGenerationRuntimeContext { + write_retained_stage_result(root, run_id, endpoint, request_body) + .unwrap_or_else(|error| panic!("write retained reference contract ledger: {error}")) + } + + let temporary = tempfile::tempdir().expect("create retained reference contract project"); + let root = temporary.path(); + init_local_game_project_at(root, "retained-reference-contract", "参考合同测试") + .expect("init retained reference contract project"); + let _platform_session = crate::platform_session::install_test_platform_session( + "retained-reference-contract-user", + "retained-reference-contract-key", + "http://127.0.0.1:9", + ); + + // 根素材带用户参考:旧实现要求引用为空,这里必须被认成合法账本。 + let icon_spec = write_retained_stage( + root, + "run-icon-spec-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": ["user-reference-1", "user-reference-2"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &icon_spec, + "icon-spec", + ) + .expect("read icon-spec ledger with user references") + ); + + // 有规范前置的生成带规范图加用户参考:旧实现要求恰好 1 项,这里必须被认成合法账本。 + let background = write_retained_stage( + root, + "run-background-canonical-and-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "game-background", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "background" } }, + "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &background, + "game-background", + ) + .expect("read game-background ledger with a canonical and a user reference") + ); + + // 图集仍只接受唯一规范引用。 + let spritesheet = write_retained_stage( + root, + "run-spritesheet-canonical-reference", + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "referenceId": "resource-icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "art" } }, + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &spritesheet, + "art-spritesheet", + ) + .expect("read art-spritesheet ledger with the canonical reference") + ); + + // 超出总上限的参考集合不能被当成同一份请求合同。 + let over_limit = write_retained_stage( + root, + "run-icon-spec-over-limit", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [ + "user-reference-1", + "user-reference-2", + "user-reference-3", + "user-reference-4", + "user-reference-5", + "user-reference-6", + ], + }), + ); + assert!( + !retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &over_limit, + "icon-spec", + ) + .expect("read over limit icon-spec ledger") + ); + + // 空白引用连账本都写不进去:写入后的读回校验必须直接失败关闭。 + let blank = write_retained_stage_result( + root, + "run-icon-spec-blank-reference", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [" "], + }), + ) + .expect_err("blank references must not be persisted into the ledger"); + assert!(blank.contains("引用资源 ID 无效"), "{blank}"); + } + fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index fb0915dce..a5a201fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -378,6 +378,78 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + // 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入, + // 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。 + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + for task_id in ["art-director", "design-foundation"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}")); + } + + // 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "resource-icon-spec".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("a leading user reference must not replace the canonical spec") + .contains("未绑定当前统一视觉规范图的本地内容身份") + ); + + // 只接受单规范引用的图集不接受额外用户参考。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("art spritesheet must reject extra user references") + .contains("未精确引用当前统一视觉规范图") + ); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = Vec::new(); + let art_spec_path = root.join("assets/art-spec.png"); let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) @@ -1019,6 +1091,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { "canvas-project-1", ); } + if root.join("assets/user-reference.png").is_file() { + bind_canvas_visual_asset_fixture_to_current_editor( + root, + "assets/user-reference.png", + "canvas-project-1", + ); + } request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); @@ -1087,9 +1166,385 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""kind":"ui-design""#)); assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 + register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image"); + let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); + let icon_spec_config_dir = unique_project_path(); + let icon_spec_with_reference = capture_generation_request( + &root, + &icon_spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/icon-spec-custom.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "带用户参考的图标规范".to_string(), + reference_asset_ids: vec![ + user_reference_asset_id.clone(), + user_reference_asset_id.clone(), + ], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations ")); + // 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。 + assert!( + icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#), + "{icon_spec_with_reference}" + ); + + let ui_with_reference_config_dir = unique_project_path(); + let ui_with_reference = capture_generation_request( + &root, + &ui_with_reference_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype-custom.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "ui-prototype".to_string(), + asset_label: "带用户参考的界面原型图".to_string(), + reference_asset_ids: vec![user_reference_asset_id.clone()], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + // 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。 + assert!( + ui_with_reference + .contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#), + "{ui_with_reference}" + ); + fs::remove_dir_all(root).ok(); fs::remove_dir_all(spec_config_dir).ok(); fs::remove_dir_all(ui_config_dir).ok(); + fs::remove_dir_all(icon_spec_config_dir).ok(); + fs::remove_dir_all(ui_with_reference_config_dir).ok(); +} + +/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。 +fn is_reference_upload_ticket_request(request: &str) -> bool { + request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") +} + +/// 图片生成提交:同样两种前缀都要算。 +fn is_image_generation_request(request: &str) -> bool { + request.starts_with("POST /api/editor/images/generations ") + || request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") + || request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") +} + +/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。 +fn manifest_asset_id(root: &Path, local_path: &str) -> String { + read_manifest_for_project(root) + .expect("read manifest for asset id") + .assets + .into_iter() + .find(|asset| asset.local_path == local_path) + .unwrap_or_else(|| panic!("registered asset is present: {local_path}")) + .id +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + // 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。 + let canvas_base_url = + spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-guard-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference guard config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference guard config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-guard", "参考素材门禁") + .expect("init reference guard project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow reference guard generation"); + let options = + |asset_kind: &str, reference_asset_ids: Vec| PlatformArtAssetGenerationOptions { + output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), + asset_kind: asset_kind.to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids, + ..PlatformArtAssetGenerationOptions::default() + }; + async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { + request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) + .await + .expect_err("reference selection must fail closed") + } + let canvas_source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + + // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 + let error = reject( + &root, + options("art-spritesheet", vec!["user-asset".to_string()]), + ) + .await; + assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); + + // 路径、URL 等形状不是素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + ] { + let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); + } + + // 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。 + let error = reject( + &root, + options( + "icon-spec", + vec!["editor-resource-from-older-account".to_string()], + ), + ) + .await; + assert!( + error.contains("参考素材不在当前项目已登记清单中"), + "{error}" + ); + + // 非图片素材不能当参考。 + fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir"); + fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture"); + register_local_asset_at( + &root, + "assets/document.json", + "image", + "application/json", + "canvas", + canvas_source(), + ) + .expect("register non image reference fixture"); + let error = reject( + &root, + options( + "icon-spec", + vec![manifest_asset_id(&root, "assets/document.json")], + ), + ) + .await; + assert!(error.contains("参考素材必须是图片"), "{error}"); + + // 已登记但本地文件缺失的素材不能被引用。 + register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image"); + let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); + fs::remove_file(root.join("assets/missing-reference.png")) + .expect("remove missing reference fixture file"); + let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); + + // 超过总上限:无规范前置最多 5 张。 + let error = reject( + &root, + options( + "icon-spec", + (0..6).map(|index| format!("reference-{index}")).collect(), + ), + ) + .await; + assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); + + // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + fs::write( + root.join("assets/vector-reference.svg"), + b"", + ) + .expect("write svg reference fixture"); + register_local_asset_at( + &root, + "assets/vector-reference.svg", + "image", + "image/svg+xml", + "canvas", + canvas_source(), + ) + .expect("register svg reference fixture"); + let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), vector_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + // 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。 + fs::write( + root.join("assets/mislabeled-reference.svg"), + valid_test_png_bytes(), + ) + .expect("write mislabeled svg reference fixture"); + register_local_asset_at( + &root, + "assets/mislabeled-reference.svg", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register mislabeled svg reference fixture"); + let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), mislabeled_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + fs::write(root.join("assets/broken-reference.png"), b"not-a-png") + .expect("write broken reference fixture"); + register_local_asset_at( + &root, + "assets/broken-reference.png", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register broken reference fixture"); + let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), broken_asset_id], + ), + ) + .await; + assert!(error.contains("不是可解析图片"), "{error}"); + + // 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。 + // 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。 + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + } + + // 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。 + let differential = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &options("icon-spec", vec![plain_reference_asset_id.clone()]), + ) + .await; + let mut upload_ticket_attempted = false; + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) { + if is_reference_upload_ticket_request(&request) { + upload_ticket_attempted = true; + } + } + assert!( + upload_ticket_attempted, + "the same single reference must attempt an upload ticket: {differential:?}" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_upload_permission_gate_fails_closed_before_any_upload() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-permission-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference permission config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference permission config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-permission", "参考上传门禁") + .expect("init reference permission project"); + // 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。 + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.upload".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny reference upload"); + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + + let error = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &PlatformArtAssetGenerationOptions { + output_path: Some("assets/reference-permission.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids: vec![plain_reference_asset_id], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await + .expect_err("a denied asset.upload must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:asset.upload"), + "{error}" + ); + + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From fbe95591d521565fe6c4b263dae52f6441c3b68a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:31:16 +0800 Subject: [PATCH 25/68] =?UTF-8?q?=E6=A0=A1=E5=87=86=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E6=92=A4=E9=94=80=E4=BD=9C=E7=94=A8=E5=9F=9F=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确切模式后新操作会替换旧撤销栈但不会跨写布局 --- .../src/view/project-development/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8f003568e..90f4bad70 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1700,8 +1700,8 @@ export default function ProjectDevelopmentView({ * * 撤销栈按「项目 + 排序模式」隔离:两种排序各有一份独立的布局 sidecar,同一份历史套用到 * 另一份 sidecar 上就是把坐标写进别人的文件。作用域记在状态里、读的时候先比对——切模式后 - * 当前历史按空栈看待,但那一段历史仍然留在状态里(切回来还能继续撤销),也不会被当成 - * 当前作用域的历史被撤销消费掉。 + * 当前历史按空栈看待;另一模式尚未产生布局操作时,切回可继续撤销。新作用域一旦写入 + * 历史便替换旧栈,旧栈不会被用于修改另一份 sidecar。 */ const resourceCanvasHistoryScopeKey = JSON.stringify([ projectPath, From 1a7abfd0599490278ac70ef0f1a7739d41987161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Thu, 17 Sep 2026 18:34:51 +0800 Subject: [PATCH 26/68] =?UTF-8?q?AGC=20=E9=A1=B9=E7=9B=AE=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=BF=AB=E7=85=A7=E4=B8=8A=E4=BC=A0=EF=BC=88=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=20OSS=20agc-dev=EF=BC=89=20(#400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 交付内容 - 客户端新增 `project_snapshot` 模块:项目扫描与排除口径、增量索引、差异对比、上传编排、状态查询。 - 触发:工作区窗口存活期间的周期定时器 + 工作区窗口关闭(`CloseRequested`);应用退出只在有界预算内等待在途同步收尾,不重复发起。 - 服务端新增两条登录态路由 `POST /api/agc/project-snapshots/files` 与 `/manifest`,由 api-server 用服务端凭据写入私有前缀 `agc/project-snapshots/v1/{user}/{project}/`;客户端不持有 OSS 凭据、不直连 OSS。 - `platform-oss` 新增内部对象精确写入与探测、项目快照对象键构造;修复 `head_internal_object` 读 HEAD 响应长度恒为 0(会导致服务端"已存在即跳过"永不生效)。 - `shared-contracts` 新增 `agc_project_snapshots` DTO 与项目 ID、相对路径、摘要校验。 - 真实 OSS 存储层冒烟示例 + 客户端真实链路冒烟用例(`#[ignore]`,env 驱动)。 - 登记 `check-config.mjs` 的 native-only 命令白名单,恢复 `npm run agc` 可启动。 ## 验证证据 - 存储层:`cargo run -p platform-oss --example agc_project_snapshot_live_smoke` 对真实 `agc-dev` 完成写入 → 读回(contentLength 32)→ 清单写入 → 探针清理。 - HTTP 层:登录态下 7 项校验(401 无 token、400 路径穿越/长度不一致/非法摘要/空正文/重复路径清单)全部符合预期且不写对象。 - 客户端链路(真实项目 gameagent-033b6cf3…):第一次 `synced uploaded=6 uploadedBytes=36694 remoteSkipped=6`,紧接着第二次 `no-op uploaded=0`。 - GUI 触发:`trigger=periodic ... uploaded=6`;把周期设为 600 秒排除干扰、改一个文件后关窗得到 `trigger=project-close ... revision=2 uploaded=1`,索引摘要由 `28d837cd84f8ab62` 推进到 `bff34e2e336d901f`(同步在进程退出前完成)。 - 门禁:AGC `project_snapshot` 14 passed(+1 ignored live)、api-server `project_snapshots` 3 passed、platform-oss 39 passed、shared-contracts 87 passed、`cargo fmt --check`(两处)、`check:encoding`、`check:doc-index`、`git diff --check`、`check-config.mjs` 全部通过。 ## 已知未决(不阻塞本里程碑) - 远端对象只增不减:没有删除路径,也没有 bucket 生命周期规则;要收口需要先定保留语义(清单引用 GC 还是 OSS 生命周期)。 - 部署环境需确认 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 或回退的 `ALIYUN_OSS_*` 具备目标 bucket 私有前缀的 `PutObject` 权限;未配置时接口返回 503、客户端失败关闭(不推进索引)。 - 缺少按用户/项目的配额与限流(error_reports 有每小时提交上限的先例,本功能没有)。 - 没有界面入口与状态可见性:`read_local_project_snapshot_state` 已注册但未接 UI,失败只写本机日志。 ## 不做 - 不做云端下载/恢复、跨设备合并、版本回滚。 - 不改 `/api/external/v1` 与 External OpenAPI,不新增 SpacetimeDB 表。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/400 --- .env.example | 9 + .../scripts/check-config.mjs | 4 + .../src-tauri/src/main.rs | 9 + .../src-tauri/src/project_snapshot/diff.rs | 151 +++++ .../src-tauri/src/project_snapshot/index.rs | 131 ++++ .../src-tauri/src/project_snapshot/mod.rs | 547 ++++++++++++++++ .../src-tauri/src/project_snapshot/scan.rs | 157 +++++ .../src-tauri/src/project_snapshot/tests.rs | 590 ++++++++++++++++++ .../src/project_snapshot/transport.rs | 337 ++++++++++ deploy/env/api-server.env.example | 10 + ...实施计划】AGC项目定时快照上传-2026-09-17.md | 43 ++ ...里程碑】AGC项目定时快照上传-2026-09-17.md | 49 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 53 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 44 ++ server-rs/crates/api-server/src/app.rs | 1 + server-rs/crates/api-server/src/config.rs | 31 + server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/modules.rs | 1 + .../src/modules/project_snapshots.rs | 31 + .../api-server/src/project_snapshots.rs | 517 +++++++++++++++ server-rs/crates/api-server/src/state.rs | 71 +++ .../agc_project_snapshot_live_smoke.rs | 278 +++++++++ server-rs/crates/platform-oss/src/lib.rs | 344 +++++++++- .../src/agc_project_snapshots.rs | 139 +++++ server-rs/crates/shared-contracts/src/lib.rs | 1 + 25 files changed, 3543 insertions(+), 6 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs create mode 100644 docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md create mode 100644 server-rs/crates/api-server/src/modules/project_snapshots.rs create mode 100644 server-rs/crates/api-server/src/project_snapshots.rs create mode 100644 server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs create mode 100644 server-rs/crates/shared-contracts/src/agc_project_snapshots.rs diff --git a/.env.example b/.env.example index bf06357e1..f11a17f8f 100644 --- a/.env.example +++ b/.env.example @@ -158,6 +158,15 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600" ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520" ALIYUN_OSS_SUCCESS_ACTION_STATUS="200" +# AGC 项目定时快照上传目标。对象只落在服务端私有前缀 +# `agc/project-snapshots/v1/{user}/{project}/` 下,客户端直传票据不覆盖该前缀。 +# bucket 与凭据可以与资源 bucket 分离;凭据未设置时回退使用 ALIYUN_OSS_ACCESS_KEY_*, +# 但 bucket / endpoint 默认指向 AGC 发行 bucket,需要该凭据具备目标 bucket 的 PutObject 权限。 +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET="agc-dev" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT="oss-rg-china-mainland.aliyuncs.com" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID="" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET="" + # BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的 # `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。 # `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。 diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index e32dfb4b7..ab7a0270c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -121,6 +121,10 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', + // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用; + // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。 + 'read_local_project_snapshot_state', + 'sync_local_project_snapshot', 'reset_design_agent_session', 'stop_local_game_preview_if_matches', 'start_game_creator_external_mcp', diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7aafd8b45..60d3113a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -106,6 +106,7 @@ mod preview; mod process_session; mod process_session_bridge; mod project; +mod project_snapshot; mod provider_handoff; mod provider_retry; mod repository_context; @@ -148,6 +149,7 @@ use plugin_host::{ use preview::*; use process_session::*; use project::*; +use project_snapshot::*; use repository_context::*; use resource_inspect::*; use resource_preview_scheduler::*; @@ -2125,6 +2127,9 @@ where fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { if matches!(event, tauri::RunEvent::Exit) { + // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 + // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 + wait_for_project_snapshot_syncs_on_exit(); // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 if let Err(error) = @@ -2390,6 +2395,7 @@ fn main() { .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(context_menu::init()) + .on_window_event(|window, event| handle_project_snapshot_window_event(window, event)) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) .manage(PluginHost::default()) @@ -2418,6 +2424,7 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); } @@ -2680,6 +2687,8 @@ fn main() { report_client_error, get_pending_error_reports, ack_error_reports, + sync_local_project_snapshot, + read_local_project_snapshot_state, ]) .build(tauri_context); let app = match app { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs new file mode 100644 index 000000000..c6de3f6f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs @@ -0,0 +1,151 @@ +use super::*; +use std::collections::BTreeSet; + +/// 需要上传的一个文件。摘要与字节数来自本次差异对比,上传阶段不再重新计算。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadCandidate { + pub(crate) relative_path: String, + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) checksum: String, + pub(crate) absolute_path: PathBuf, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotDiff { + pub(crate) uploads: Vec, + /// 本地已删除、只在远端清单中需要消失的路径。 + pub(crate) deleted: Vec, + /// 内容与上次同步一致、只有元数据变化的路径;只需刷新本地索引。 + pub(crate) metadata_only: Vec, + pub(crate) skipped: Vec, + /// 本轮因为累计上限没有上传、留给下一次同步的路径。 + pub(crate) deferred: Vec, + /// 同步期间被改写、本轮不参与上传与索引推进的路径。 + pub(crate) pending: Vec, + pub(crate) upload_bytes: u64, + /// 扫描后的完整清单(尚未扣除上传失败与延后项),成功同步后就是新的索引。 + pub(crate) current: BTreeMap, +} + +impl ProjectSnapshotDiff { + pub(crate) fn has_changes(&self) -> bool { + !self.uploads.is_empty() || !self.deleted.is_empty() || !self.metadata_only.is_empty() + } +} + +/// 增量差异对比:先用 `(字节数, 修改时间)` 判定是否需要读盘,只有元数据变化时 +/// 才重新计算摘要,再用摘要判断内容是否真的变了。 +pub(crate) fn compute_project_snapshot_diff( + scan: &ProjectSnapshotScanResult, + previous: &BTreeMap, + max_sync_bytes: u64, +) -> Result { + let mut diff = ProjectSnapshotDiff { + skipped: scan.skipped.clone(), + ..ProjectSnapshotDiff::default() + }; + let mut metadata_only = Vec::new(); + + for file in &scan.files { + let previous_entry = previous.get(&file.relative_path); + if let Some(entry) = previous_entry { + if entry.size_bytes == file.size_bytes + && entry.modified_ms == file.modified_ms + && !entry.checksum.is_empty() + { + diff.current + .insert(file.relative_path.clone(), entry.clone()); + continue; + } + } + + let bytes = read_project_snapshot_file_bytes(&file.absolute_path)?; + // 同步不持项目写锁:读完立刻复核一次,文件在读取期间被改写就留给下一轮, + // 既不把这份内容写进索引,也不上传它。 + if !project_snapshot_file_matches(&file.absolute_path, file.size_bytes, file.modified_ms) { + diff.pending.push(ProjectSnapshotSkippedPath { + relative_path: file.relative_path.clone(), + reason: "同步期间文件发生变化,留到下一次".to_string(), + }); + // 上一轮已同步过这条路径时沿用旧记录:文件仍然存在,只是这一轮读不到 + // 一致版本,所以既不能算删除(会被远端 GC 掉),也不能记成本轮已同步。 + if let Some(entry) = previous_entry { + diff.current + .insert(file.relative_path.clone(), entry.clone()); + } + continue; + } + let checksum = project_snapshot_checksum(&bytes); + match previous_entry { + Some(entry) if entry.checksum == checksum => { + metadata_only.push(file.relative_path.clone()) + } + _ => diff.uploads.push(ProjectSnapshotUploadCandidate { + relative_path: file.relative_path.clone(), + size_bytes: file.size_bytes, + modified_ms: file.modified_ms, + checksum: checksum.clone(), + absolute_path: file.absolute_path.clone(), + }), + } + diff.current.insert( + file.relative_path.clone(), + ProjectSnapshotIndexedFile { + size_bytes: file.size_bytes, + modified_ms: file.modified_ms, + checksum, + }, + ); + } + + diff.deleted = previous + .keys() + .filter(|relative_path| !diff.current.contains_key(*relative_path)) + .cloned() + .collect(); + diff.deleted.sort(); + diff.metadata_only = metadata_only; + + apply_project_snapshot_sync_budget(&mut diff, max_sync_bytes); + Ok(diff) +} + +/// 按路径顺序累计上传体积;超出上限的文件进入延后清单,不静默截断也不上传残缺内容。 +fn apply_project_snapshot_sync_budget(diff: &mut ProjectSnapshotDiff, max_sync_bytes: u64) { + let mut accepted = Vec::new(); + let mut deferred = Vec::new(); + let mut bytes = 0_u64; + for candidate in diff.uploads.drain(..) { + if bytes.saturating_add(candidate.size_bytes) > max_sync_bytes { + deferred.push(ProjectSnapshotSkippedPath { + relative_path: candidate.relative_path, + reason: format!("单次同步超过 {max_sync_bytes} 字节上限,留到下一次"), + }); + continue; + } + bytes = bytes.saturating_add(candidate.size_bytes); + accepted.push(candidate); + } + diff.upload_bytes = bytes; + diff.uploads = accepted; + diff.deferred = deferred; +} + +/// 成功同步后的索引清单:延后项与上传失败项都要剔除,否则下次同步会把它们 +/// 当成"已经同步过"而漏传。 +pub(crate) fn build_project_snapshot_synced_files( + diff: &ProjectSnapshotDiff, + uploaded_paths: &BTreeSet, +) -> BTreeMap { + let mut retained = diff.current.clone(); + for candidate in &diff.uploads { + if !uploaded_paths.contains(&candidate.relative_path) { + retained.remove(&candidate.relative_path); + } + } + for deferred in &diff.deferred { + retained.remove(&deferred.relative_path); + } + retained +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs new file mode 100644 index 000000000..f81f9e747 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -0,0 +1,131 @@ +use super::*; + +/// 本地增量索引里的一条文件记录。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotIndexedFile { + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) checksum: String, +} + +/// 上次成功同步的快照。索引只在本机 AppData 中,不进入用户项目目录。 +/// +/// `user_id` 是远端前缀的一部分:换号后旧索引不再代表同一个远端命名空间, +/// 因此读取时按用户身份判等,不一致就当作冷启动重新全量对比。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotIndex { + pub(crate) schema_version: u32, + pub(crate) project_id: String, + pub(crate) user_id: String, + pub(crate) sync_revision: u64, + pub(crate) synced_at_ms: u64, + #[serde(default)] + pub(crate) files: BTreeMap, +} + +pub(crate) fn empty_project_snapshot_index( + project_id: &str, + user_id: &str, +) -> ProjectSnapshotIndex { + ProjectSnapshotIndex { + schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + project_id: project_id.to_string(), + user_id: user_id.to_string(), + sync_revision: 0, + synced_at_ms: 0, + files: BTreeMap::new(), + } +} + +/// 项目 ID 同时用作 AppData 目录名与远端键段:这里额外拒绝 `.` 与 `..`,避免 +/// 把索引写到目录之外。 +pub(crate) fn validate_project_snapshot_project_id(project_id: &str) -> Result { + shared_contracts::agc_project_snapshots::validate_agc_project_snapshot_project_id(project_id)?; + if project_id == "." || project_id == ".." { + return Err("项目 ID 不能是相对目录".to_string()); + } + Ok(project_id.to_string()) +} + +pub(crate) fn project_snapshot_index_directory(project_id: &str) -> Result { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + project_snapshot_index_directory_at(&config_dir, project_id) +} + +pub(crate) fn project_snapshot_index_directory_at( + config_dir: &Path, + project_id: &str, +) -> Result { + let project_id = validate_project_snapshot_project_id(project_id)?; + Ok(config_dir.join(PROJECT_SNAPSHOT_DIRECTORY).join(project_id)) +} + +pub(crate) fn project_snapshot_index_path(project_id: &str) -> Result { + Ok(project_snapshot_index_path_at( + &project_snapshot_index_directory(project_id)?, + )) +} + +pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf { + directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME) +} + +/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下 +/// 全量对比会重新上传所有文件,不会漏传,也不会因为坏索引中断同步。 +pub(crate) fn read_project_snapshot_index( + project_id: &str, +) -> Result { + read_project_snapshot_index_at(&project_snapshot_index_directory(project_id)?, project_id) +} + +pub(crate) fn read_project_snapshot_index_at( + directory: &Path, + project_id: &str, +) -> Result { + let path = project_snapshot_index_path_at(directory); + if !path.exists() { + return Ok(empty_project_snapshot_index(project_id, "")); + } + let content = match read_game_creator_private_file_to_string( + &path, + "项目快照索引", + PROJECT_SNAPSHOT_INDEX_MAX_BYTES, + ) { + Ok(content) => content, + Err(error) => { + app_log!("project_snapshot.index.read.failed: {error}"); + return Ok(empty_project_snapshot_index(project_id, "")); + } + }; + match serde_json::from_str::(&content) { + Ok(index) + if index.schema_version == PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION + && index.project_id == project_id => + { + Ok(index) + } + Ok(_) => Ok(empty_project_snapshot_index(project_id, "")), + Err(error) => { + app_log!("project_snapshot.index.parse.failed: {error}"); + Ok(empty_project_snapshot_index(project_id, "")) + } + } +} + +pub(crate) fn write_project_snapshot_index(index: &ProjectSnapshotIndex) -> Result<(), String> { + write_project_snapshot_index_at(&project_snapshot_index_directory(&index.project_id)?, index) +} + +pub(crate) fn write_project_snapshot_index_at( + directory: &Path, + index: &ProjectSnapshotIndex, +) -> Result<(), String> { + let path = project_snapshot_index_path_at(directory); + let mut encoded = serde_json::to_vec_pretty(index) + .map_err(|error| format!("序列化项目快照索引失败:{error}"))?; + encoded.push(b'\n'); + write_game_creator_private_file(&path, &encoded, "项目快照索引") +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs new file mode 100644 index 000000000..bcdf2ce21 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -0,0 +1,547 @@ +//! AGC 项目定时快照上传。 +//! +//! 客户端在这里负责三件事:按项目目录扫描与增量差异对比、按触发时机编排上传、 +//! 把已成功同步的清单写回本机索引。远端对象键与存储凭据由 api-server 决定, +//! 客户端不持有 OSS 凭据,也不直连 OSS。 + +use super::*; +use std::collections::BTreeSet; +use std::sync::{Condvar, MutexGuard}; +use std::time::Instant; + +mod diff; +mod index; +mod scan; +mod transport; + +#[cfg(test)] +mod tests; + +pub(crate) use diff::*; +pub(crate) use index::*; +pub(crate) use scan::*; +pub(crate) use transport::*; + +/// 本机项目快照索引在 AppData 配置目录下的位置。 +pub(crate) const PROJECT_SNAPSHOT_DIRECTORY: &str = "project-snapshots"; +const PROJECT_SNAPSHOT_INDEX_FILE_NAME: &str = "index.json"; +const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 1; +const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024; + +pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES; +pub(crate) const PROJECT_SNAPSHOT_MAX_SYNC_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_SYNC_BYTES; +/// 单项目常驻占用上限。超过时不再尝试上传,直接给出明确失败而不是反复被服务端拒绝。 +pub(crate) const PROJECT_SNAPSHOT_MAX_PROJECT_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES; + +const PROJECT_SNAPSHOT_FILES_ENDPOINT: &str = "/api/agc/project-snapshots/files"; +const PROJECT_SNAPSHOT_MANIFEST_ENDPOINT: &str = "/api/agc/project-snapshots/manifest"; + +/// 单个请求的完成上限;关闭与退出路径依赖它保持有界。 +const PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS: u64 = 60; +/// 应用退出前等待在途同步完成的上限。 +const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; +const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; +const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; +const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DISABLED"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectSnapshotSyncTrigger { + Periodic, + ProjectClose, + Manual, +} + +impl ProjectSnapshotSyncTrigger { + fn as_str(self) -> &'static str { + match self { + Self::Periodic => "periodic", + Self::ProjectClose => "project-close", + Self::Manual => "manual", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotFailureView { + pub(crate) relative_path: String, + pub(crate) code: String, + pub(crate) detail: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotSyncReport { + pub(crate) project_id: String, + pub(crate) trigger: String, + /// `synced` 全部成功,`partial` 部分成功,`failed` 全部失败,`no-op` 无改动。 + pub(crate) status: String, + pub(crate) sync_revision: u64, + pub(crate) uploaded_files: usize, + pub(crate) uploaded_bytes: u64, + pub(crate) remote_skipped_files: usize, + pub(crate) deleted_files: usize, + pub(crate) deferred_files: usize, + pub(crate) metadata_only_files: usize, + pub(crate) skipped_files: Vec, + /// 同步期间被改写、本轮未参与上传与索引推进的路径。 + pub(crate) pending_files: Vec, + pub(crate) failed_files: Vec, + pub(crate) synced_at_ms: u64, +} + +/// 同步开关。停用时不发起任何请求,也不推进索引。 +pub(crate) fn project_snapshot_sync_enabled() -> bool { + match std::env::var_os(PROJECT_SNAPSHOT_DISABLED_ENV) { + Some(value) => { + let value = value.to_string_lossy().trim().to_ascii_lowercase(); + !matches!(value.as_str(), "1" | "true" | "yes" | "on") + } + None => true, + } +} + +pub(crate) fn project_snapshot_sync_interval() -> Duration { + let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); + Duration::from_secs(seconds) +} + +/// 同一项目的同步串行执行;周期触发遇到在途同步直接让位,不排队堆积。 +static PROJECT_SNAPSHOT_PROJECT_LOCKS: OnceLock>>>> = + OnceLock::new(); + +struct ProjectSnapshotInFlight { + active: Mutex, + idle: Condvar, +} + +static PROJECT_SNAPSHOT_IN_FLIGHT: OnceLock = OnceLock::new(); + +fn project_snapshot_project_locks() -> &'static Mutex>>> { + PROJECT_SNAPSHOT_PROJECT_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +fn project_snapshot_in_flight() -> &'static ProjectSnapshotInFlight { + PROJECT_SNAPSHOT_IN_FLIGHT.get_or_init(|| ProjectSnapshotInFlight { + active: Mutex::new(0), + idle: Condvar::new(), + }) +} + +fn project_snapshot_project_lock(key: &str) -> Arc> { + let mut locks = project_snapshot_project_locks() + .lock() + .expect("project snapshot lock registry"); + locks + .entry(key.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +struct ProjectSnapshotInFlightGuard; + +impl ProjectSnapshotInFlightGuard { + fn begin() -> Self { + let state = project_snapshot_in_flight(); + let mut active = state + .active + .lock() + .expect("project snapshot in-flight lock"); + *active += 1; + Self + } +} + +impl Drop for ProjectSnapshotInFlightGuard { + fn drop(&mut self) { + let state = project_snapshot_in_flight(); + if let Ok(mut active) = state.active.lock() { + *active = active.saturating_sub(1); + if *active == 0 { + state.idle.notify_all(); + } + } + } +} + +/// 周期触发:已有同项目同步在途时返回 `None`。 +pub(crate) fn try_run_project_snapshot_sync(key: &str, run: impl FnOnce() -> T) -> Option { + let lock = project_snapshot_project_lock(key); + let guard = lock.try_lock().ok()?; + let _in_flight = ProjectSnapshotInFlightGuard::begin(); + let result = run(); + drop(guard); + Some(result) +} + +/// 关闭与手动触发:等待在途同步结束后再执行一次,保证最后一次状态被记录。 +pub(crate) fn run_project_snapshot_sync(key: &str, run: impl FnOnce() -> T) -> T { + let lock = project_snapshot_project_lock(key); + let guard: MutexGuard<'_, ()> = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _in_flight = ProjectSnapshotInFlightGuard::begin(); + let result = run(); + drop(guard); + result +} + +/// 退出前等待在途同步收尾。返回是否在预算内全部结束。 +pub(crate) fn wait_for_project_snapshot_syncs(timeout: Duration) -> bool { + let state = project_snapshot_in_flight(); + let deadline = Instant::now() + timeout; + let mut active = state + .active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while *active > 0 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (guard, _) = state + .idle + .wait_timeout(active, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + active = guard; + } + true +} + +/// 把项目同步请求交给后台线程:窗口关闭与应用退出路径都不能被网络等待阻塞。 +pub(crate) fn request_project_snapshot_sync( + project_root: PathBuf, + trigger: ProjectSnapshotSyncTrigger, +) { + if !project_snapshot_sync_enabled() { + return; + } + let key = project_snapshot_sync_key(&project_root); + let spawn = std::thread::Builder::new() + .name("agc-project-snapshot".to_string()) + .spawn(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + app_log!( + "project_snapshot.sync.failed trigger={}: {error}", + trigger.as_str() + ); + } + }); + if let Err(error) = spawn { + app_log!("project_snapshot.sync.spawn.failed: {error}"); + } +} + +pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { + let value = project_root.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + { + return value.trim_end_matches('/').to_ascii_lowercase(); + } + #[cfg(not(windows))] + { + value.trim_end_matches('/').to_string() + } +} + +fn sync_project_snapshot_blocking( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) +} + +async fn sync_project_snapshot_async( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + if !project_snapshot_sync_enabled() { + return Err("项目快照同步已停用".to_string()); + } + validate_project_root(project_root)?; + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + let manifest = read_existing_manifest_for_project(project_root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + + let previous = read_project_snapshot_index(&project_id)?; + let previous = if previous.user_id == session.user_id { + previous + } else { + empty_project_snapshot_index(&project_id, &session.user_id) + }; + + let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?; + // 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。 + let scan_total_bytes = scan + .files + .iter() + .fold(0_u64, |total, file| total.saturating_add(file.size_bytes)); + if scan_total_bytes > PROJECT_SNAPSHOT_MAX_PROJECT_BYTES { + return Err(format!( + "项目体积 {scan_total_bytes} 字节超过项目快照单项目上限 {PROJECT_SNAPSHOT_MAX_PROJECT_BYTES} 字节" + )); + } + let diff = + compute_project_snapshot_diff(&scan, &previous.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)?; + if !diff.has_changes() { + return Ok(ProjectSnapshotSyncReport { + project_id, + trigger: trigger.as_str().to_string(), + status: "no-op".to_string(), + sync_revision: previous.sync_revision, + uploaded_files: 0, + uploaded_bytes: 0, + remote_skipped_files: 0, + deleted_files: 0, + deferred_files: 0, + metadata_only_files: diff.metadata_only.len(), + skipped_files: failure_views(&diff.skipped), + pending_files: failure_views(&diff.pending), + failed_files: Vec::new(), + synced_at_ms: previous.synced_at_ms, + }); + } + + let upload = upload_project_snapshot_diff(&session, &project_id, &diff).await; + let synced_files = build_project_snapshot_synced_files(&diff, &upload.uploaded_paths); + let next_revision = previous.sync_revision.saturating_add(1); + let synced_at_ms = u64::try_from(unix_millis()).unwrap_or(u64::MAX); + let payload = shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestRequest { + schema_version: + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, + project_id: project_id.clone(), + sync_revision: next_revision, + synced_at_ms, + files: synced_files + .iter() + .map(|(relative_path, file)| { + shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestFile { + relative_path: relative_path.clone(), + size_bytes: file.size_bytes, + checksum: file.checksum.clone(), + } + }) + .collect(), + }; + // 清单失败就不推进索引:宁可下一次重算,也不能把本地记成"已同步"。 + upload_project_snapshot_manifest(&session, &payload) + .await + .map_err(|error| error.message())?; + write_project_snapshot_index(&ProjectSnapshotIndex { + schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + project_id: project_id.clone(), + user_id: session.user_id.clone(), + sync_revision: next_revision, + synced_at_ms, + files: synced_files, + })?; + + let status = if upload.failures.is_empty() { + "synced" + } else if upload.uploaded_paths.is_empty() { + "failed" + } else { + "partial" + }; + let report = ProjectSnapshotSyncReport { + project_id, + trigger: trigger.as_str().to_string(), + status: status.to_string(), + sync_revision: next_revision, + uploaded_files: upload.uploaded_paths.len(), + uploaded_bytes: upload.uploaded_bytes, + remote_skipped_files: upload.remote_skipped_files, + deleted_files: diff.deleted.len(), + deferred_files: diff.deferred.len(), + metadata_only_files: diff.metadata_only.len(), + skipped_files: failure_views(&diff.skipped), + pending_files: failure_views(&diff.pending), + failed_files: upload + .failures + .iter() + .map(|failure| ProjectSnapshotFailureView { + relative_path: failure.relative_path.clone(), + code: failure.code.clone(), + detail: failure.detail.clone(), + }) + .collect(), + synced_at_ms, + }; + app_log!( + "project_snapshot.sync.completed trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={}", + report.trigger, + report.status, + report.sync_revision, + report.uploaded_files, + report.remote_skipped_files, + report.deleted_files, + report.deferred_files, + report.failed_files.len() + ); + Ok(report) +} + +fn failure_views(skipped: &[ProjectSnapshotSkippedPath]) -> Vec { + skipped + .iter() + .map(|entry| ProjectSnapshotFailureView { + relative_path: entry.relative_path.clone(), + code: "skipped".to_string(), + detail: entry.reason.clone(), + }) + .collect() +} + +/// 从窗口 URL 读取项目路径。只有带 `projectPath` 的窗口才代表打开的项目。 +pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option { + url.query_pairs() + .find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned())) + .filter(|value| !value.trim().is_empty()) +} + +fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let mut paths = BTreeSet::new(); + for window in app.webview_windows().into_values() { + let Ok(url) = window.url() else { + continue; + }; + if let Some(project_path) = project_snapshot_project_path_from_url(&url) { + paths.insert(project_path); + } + } + paths.into_iter().collect() +} + +/// 工作区窗口关闭即视为项目关闭:立刻补一次同步。 +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if !project_snapshot_sync_enabled() { + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + // `tauri::Window` 不暴露 WebView 地址,按标签取回对应的 WebView 窗口再读 URL。 + let Some(webview) = window.app_handle().get_webview_window(window.label()) else { + return; + }; + let Ok(url) = webview.url() else { + return; + }; + let Some(project_path) = project_snapshot_project_path_from_url(&url) else { + return; + }; + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); +} + +/// 应用退出前等待在途同步收尾。 +/// +/// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: +/// 关窗触发的同步已经在 `CloseRequested` 时启动,退出路径只需要给它一个有界窗口。 +pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { + if !project_snapshot_sync_enabled() { + return; + } + if !wait_for_project_snapshot_syncs(Duration::from_secs(PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS)) { + app_log!("project_snapshot.sync.exit.budget-exhausted"); + } +} + +/// 周期定时器:只为当前仍打开的项目触发,进程内项目集合由窗口 URL 决定。 +pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { + if !project_snapshot_sync_enabled() { + app_log!("project_snapshot.scheduler.disabled"); + return; + } + let interval = project_snapshot_sync_interval(); + let spawned = std::thread::Builder::new() + .name("agc-project-snapshot-scheduler".to_string()) + .spawn(move || loop { + std::thread::sleep(interval); + for project_path in open_project_snapshot_workspaces(&app) { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::Periodic, + ); + } + }); + if let Err(error) = spawned { + app_log!("project_snapshot.scheduler.spawn.failed: {error}"); + } +} + +fn resolve_project_snapshot_root(project_path: &str) -> Result { + let trimmed = project_path.trim(); + if trimmed.is_empty() { + return Err("请提供项目绝对路径".to_string()); + } + let root = PathBuf::from(trimmed); + validate_project_root(&root)?; + Ok(root) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotStateView { + project_id: String, + index_path: String, + index_present: bool, + file_count: usize, + sync_revision: u64, + synced_at_ms: u64, + enabled: bool, +} + +/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 +#[tauri::command] +pub(crate) async fn sync_local_project_snapshot( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let key = project_snapshot_sync_key(&root); + tauri::async_runtime::spawn_blocking(move || { + run_project_snapshot_sync(&key, || { + sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) + }) + }) + .await + .map_err(|error| format!("项目快照同步任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_snapshot_state( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let manifest = read_existing_manifest_for_project(&root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + let index_path = project_snapshot_index_path(&project_id)?; + let index = read_project_snapshot_index(&project_id)?; + Ok(ProjectSnapshotStateView { + project_id, + index_path: index_path.to_string_lossy().into_owned(), + index_present: index_path.exists(), + file_count: index.files.len(), + sync_revision: index.sync_revision, + synced_at_ms: index.synced_at_ms, + enabled: project_snapshot_sync_enabled(), + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs new file mode 100644 index 000000000..2163c4b24 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs @@ -0,0 +1,157 @@ +use super::*; + +/// 扫描阶段的候选文件:只看元数据,不读内容,差异对比阶段才决定是否读盘。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotScannedFile { + pub(crate) relative_path: String, + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) absolute_path: PathBuf, +} + +/// 被排除或不参与本次同步的路径。原因只用于本机日志与同步报告,不进入远端。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotSkippedPath { + pub(crate) relative_path: String, + pub(crate) reason: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotScanResult { + pub(crate) files: Vec, + pub(crate) skipped: Vec, +} + +/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径: +/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点 +/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。 +pub(crate) fn scan_project_snapshot_files( + root: &Path, + max_file_bytes: u64, +) -> Result { + let mut result = ProjectSnapshotScanResult::default(); + if !root.exists() { + return Ok(result); + } + let root_metadata = + fs::symlink_metadata(root).map_err(|error| format!("读取项目目录元数据失败:{error}"))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("项目快照只支持普通项目目录".to_string()); + } + + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + let entries = fs::read_dir(&directory) + .map_err(|error| format!("读取项目目录失败:{}: {error}", directory.display()))?; + for entry in entries { + let entry = entry + .map_err(|error| format!("读取项目文件失败:{}: {error}", directory.display()))?; + let path = entry.path(); + // 路径不在项目根内(含符号链接跳转)时直接跳过,不猜测归属。 + let Ok(relative_path) = relative_project_path(root, &path) else { + continue; + }; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: format!("读取元数据失败:{error}"), + }); + continue; + } + }; + if should_skip_project_snapshot_path(&relative_path) { + continue; + } + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: "符号链接或重解析点不参与项目快照".to_string(), + }); + continue; + } + if metadata.is_dir() { + directories.push(path); + continue; + } + if !metadata.is_file() { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: "不是普通文件".to_string(), + }); + continue; + } + if metadata.len() > max_file_bytes { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: format!("单个文件超过 {max_file_bytes} 字节上限"), + }); + continue; + } + result.files.push(ProjectSnapshotScannedFile { + relative_path, + size_bytes: metadata.len(), + modified_ms: project_snapshot_modified_ms(&metadata), + absolute_path: path, + }); + } + } + + result + .files + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + result + .skipped + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(result) +} + +/// 修改时间缺失或早于 Unix 纪元时返回 0:这样的文件每轮都会重算摘要,不会 +/// 被误判成"未改动"。 +pub(crate) fn project_snapshot_modified_ms(metadata: &fs::Metadata) -> u64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +/// 安全打开并读取项目文件内容。拒绝符号链接、重解析点与硬链接,读取后不再 +/// 按路径名二次访问。 +pub(crate) fn read_project_snapshot_file_bytes(path: &Path) -> Result, String> { + let (mut file, metadata) = open_project_snapshot_regular_file(path, "项目快照文件")?; + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default()); + file.read_to_end(&mut bytes) + .map_err(|error| format!("读取项目快照文件失败:{}: {error}", path.display()))?; + Ok(bytes) +} + +/// 与项目索引同口径的校验和,便于同一份内容在不同入口之间比较。 +pub(crate) fn project_snapshot_checksum(bytes: &[u8]) -> String { + format!("fnv1a64:{:016x}", fnv1a64(bytes)) +} + +/// 复核文件是否仍是扫描时刻的那一份内容。 +/// +/// 同步不持有项目写锁:Agent 或编辑器可能在扫描之后、读取或上传之前改写文件。 +/// 读取前后都按 `(字节数, 修改时间)` 比对,任一处不一致就判定为"同步期间发生变化", +/// 该文件不计入索引、留给下一次同步,而不是上传一份自相矛盾的快照。 +pub(crate) fn project_snapshot_file_matches( + path: &Path, + size_bytes: u64, + modified_ms: u64, +) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return false; + } + if !metadata.is_file() { + return false; + } + metadata.len() == size_bytes && project_snapshot_modified_ms(&metadata) == modified_ms +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs new file mode 100644 index 000000000..ff57bdfae --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -0,0 +1,590 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn fixture_root() -> tempfile::TempDir { + tempfile::tempdir().expect("create project snapshot fixture root") +} + +fn write_fixture_file(root: &Path, relative_path: &str, bytes: &[u8]) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create fixture parent directory"); + } + fs::write(&path, bytes).expect("write fixture file"); +} + +fn scan_fixture(root: &Path) -> ProjectSnapshotScanResult { + scan_project_snapshot_files(root, PROJECT_SNAPSHOT_MAX_FILE_BYTES) + .expect("scan fixture project") +} + +fn paths_of(files: &[ProjectSnapshotUploadCandidate]) -> Vec { + files + .iter() + .map(|candidate| candidate.relative_path.clone()) + .collect() +} + +#[test] +fn project_snapshot_project_id_validation_rejects_paths_and_unsafe_values() { + for value in [ + "", + " ", + ".", + "..", + "../escape", + "a/b", + "a\\b", + "a b", + "-leading", + ".hidden", + ] { + assert!( + validate_project_snapshot_project_id(value).is_err(), + "unsafe project id must be rejected: {value}" + ); + } + assert_eq!( + validate_project_snapshot_project_id("gameagent-1a2b3c4d").expect("stable project id"), + "gameagent-1a2b3c4d" + ); +} + +#[test] +fn project_snapshot_index_round_trips_and_recovers_from_corruption() { + let root = fixture_root(); + let directory = + project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); + assert!(read_project_snapshot_index_at(&directory, "project-1") + .expect("missing index reads as empty") + .files + .is_empty()); + + let mut index = empty_project_snapshot_index("project-1", "user-1"); + index.sync_revision = 3; + index.synced_at_ms = 1_700_000_000_000; + index.files.insert( + "game/index.html".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 13, + modified_ms: 34, + checksum: "fnv1a64:0000000000000001".to_string(), + }, + ); + write_project_snapshot_index_at(&directory, &index).expect("write index"); + assert_eq!( + read_project_snapshot_index_at(&directory, "project-1").expect("read index"), + index + ); + + let path = project_snapshot_index_path_at(&directory); + fs::write(&path, b"{ not json").expect("corrupt index"); + assert!( + read_project_snapshot_index_at(&directory, "project-1") + .expect("corrupt index reads as empty") + .files + .is_empty(), + "损坏索引必须退化为空索引,让下一次同步全量重算" + ); +} + +#[test] +fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/index.html", b""); + write_fixture_file(root.path(), "assets/manifest.json", b"{}"); + write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}"); + write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); + write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};"); + write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle"); + write_fixture_file(root.path(), "secrets/key.pem", b"private-key"); + write_fixture_file(root.path(), "assets/big.bin", b"0123456789"); + + let scan = scan_project_snapshot_files(root.path(), 4).expect("scan fixture project"); + let scanned = scan + .files + .iter() + .map(|file| file.relative_path.clone()) + .collect::>(); + assert_eq!( + scanned, + vec!["assets/manifest.json".to_string()], + ".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合" + ); + let skipped = scan + .skipped + .iter() + .map(|entry| entry.relative_path.clone()) + .collect::>(); + assert_eq!( + skipped, + vec!["assets/big.bin".to_string(), "game/index.html".to_string()], + "超限文件进入跳过清单,不静默丢弃" + ); +} + +#[test] +fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + write_fixture_file(root.path(), "game/b.txt", b"beta!"); + + let first_scan = scan_fixture(root.path()); + let first = compute_project_snapshot_diff( + &first_scan, + &BTreeMap::new(), + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("first diff"); + assert_eq!(paths_of(&first.uploads), vec!["game/a.txt", "game/b.txt"]); + assert!(first.deleted.is_empty()); + + // 第二次:元数据未变,必须复用摘要并且不产生上传项。 + let second = + compute_project_snapshot_diff(&first_scan, &first.current, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("second diff"); + assert!(second.uploads.is_empty(), "{:?}", second.uploads); + assert!(second.deleted.is_empty()); + assert!(second.metadata_only.is_empty()); + assert!(!second.has_changes()); + assert_eq!(second.current, first.current); + + // 只改一个文件:差异集合恰好包含它。 + write_fixture_file(root.path(), "game/a.txt", b"alpha-changed"); + let third = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &first.current, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("third diff"); + assert_eq!(paths_of(&third.uploads), vec!["game/a.txt"]); + + // 删除文件只体现在清单:没有上传项,但 deleted 里能读到。 + fs::remove_file(root.path().join("game/b.txt")).expect("delete fixture file"); + let fourth = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &third.current, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("fourth diff"); + assert!(fourth.uploads.is_empty()); + assert_eq!(fourth.deleted, vec!["game/b.txt".to_string()]); +} + +#[test] +fn project_snapshot_diff_treats_touched_but_identical_content_as_metadata_only() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let scan = scan_fixture(root.path()); + let checksum = project_snapshot_checksum(b"alpha"); + let mut previous = BTreeMap::new(); + previous.insert( + "game/a.txt".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 5, + // 修改时间不同但内容一致:只能靠摘要判定,不能重复上传。 + modified_ms: scan.files[0].modified_ms.saturating_sub(1_000), + checksum, + }, + ); + let diff = compute_project_snapshot_diff(&scan, &previous, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("metadata-only diff"); + assert!(diff.uploads.is_empty()); + assert_eq!(diff.metadata_only, vec!["game/a.txt".to_string()]); + assert!(diff.has_changes()); +} + +#[test] +fn project_snapshot_diff_defers_files_over_the_sync_budget() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"0123456789"); + write_fixture_file(root.path(), "game/b.txt", b"0123456789"); + let scan = scan_fixture(root.path()); + let diff = compute_project_snapshot_diff(&scan, &BTreeMap::new(), 15).expect("budget diff"); + assert_eq!(paths_of(&diff.uploads), vec!["game/a.txt"]); + assert_eq!( + diff.deferred + .iter() + .map(|entry| entry.relative_path.clone()) + .collect::>(), + vec!["game/b.txt".to_string()] + ); + assert_eq!(diff.upload_bytes, 10); +} + +#[test] +fn project_snapshot_synced_files_exclude_failed_and_deferred_paths() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"a"); + write_fixture_file(root.path(), "game/b.txt", b"b"); + write_fixture_file(root.path(), "game/c.txt", b"c"); + let scan = scan_fixture(root.path()); + let mut diff = + compute_project_snapshot_diff(&scan, &BTreeMap::new(), PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff"); + diff.deferred.push(ProjectSnapshotSkippedPath { + relative_path: "game/c.txt".to_string(), + reason: "fixture".to_string(), + }); + let uploaded = BTreeSet::from(["game/a.txt".to_string()]); + let synced = build_project_snapshot_synced_files(&diff, &uploaded); + assert_eq!( + synced.keys().cloned().collect::>(), + vec!["game/a.txt".to_string()], + "未成功上传与延后的路径不能进入新索引,否则下一次同步会漏传" + ); +} + +#[test] +fn project_snapshot_sync_is_serialized_per_project() { + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let key = "c:/fixture/serialized-project"; + let mut handles = Vec::new(); + for _ in 0..3 { + let active = Arc::clone(&active); + let peak = Arc::clone(&peak); + handles.push(std::thread::spawn(move || { + run_project_snapshot_sync(key, || { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(current, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(30)); + active.fetch_sub(1, Ordering::SeqCst); + }); + })); + } + for handle in handles { + handle.join().expect("join serialized sync thread"); + } + assert_eq!(peak.load(Ordering::SeqCst), 1); +} + +#[test] +fn project_snapshot_periodic_trigger_yields_while_a_sync_is_in_flight() { + let key = "c:/fixture/periodic-project"; + let (started_sender, started_receiver) = mpsc::channel(); + let handle = std::thread::spawn(move || { + run_project_snapshot_sync(key, || { + started_sender.send(()).expect("signal started sync"); + std::thread::sleep(Duration::from_millis(200)); + }); + }); + started_receiver.recv().expect("wait for in-flight sync"); + assert!( + try_run_project_snapshot_sync(key, || ()).is_none(), + "在途同步期间周期触发必须直接让位" + ); + handle.join().expect("join in-flight sync"); + assert!(try_run_project_snapshot_sync(key, || ()).is_some()); +} + +fn read_http_request_with_body(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set fixture read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut buffer).expect("read fixture request"); + assert!(read > 0, "fixture request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read fixture request body"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn spawn_snapshot_fixture( + response_status: &'static str, + response_body: String, +) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture listener"); + let address = listener.local_addr().expect("read fixture address"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fixture request"); + let request = read_http_request_with_body(&mut stream); + let response = format!( + "HTTP/1.1 {response_status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write fixture response"); + request + }); + (format!("http://{address}"), handle) +} + +fn fixture_session(api_base_url: String) -> PlatformSessionSnapshot { + PlatformSessionSnapshot { + user_id: "user-fixture".to_string(), + access_token: "fixture-token".to_string(), + api_base_url, + identity_generation: 1, + revision: 1, + } +} + +fn fixture_candidate( + root: &Path, + relative_path: &str, + bytes: &[u8], +) -> ProjectSnapshotUploadCandidate { + write_fixture_file(root, relative_path, bytes); + let path = root.join(relative_path); + let metadata = fs::symlink_metadata(&path).expect("read fixture metadata"); + ProjectSnapshotUploadCandidate { + relative_path: relative_path.to_string(), + size_bytes: bytes.len() as u64, + // 用真实修改时间:上传前的一致性复核会拿它比对,写死常量会把请求提前挡掉。 + modified_ms: project_snapshot_modified_ms(&metadata), + checksum: project_snapshot_checksum(bytes), + absolute_path: path, + } +} + +#[test] +fn project_snapshot_upload_marks_remote_duplicates_and_sends_marker_headers() { + let root = fixture_root(); + let candidate = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + let body = serde_json::json!({ + "projectId": "project-1", + "relativePath": "game/a.txt", + "objectKey": "agc/project-snapshots/v1/user-fixture/project-1/game/a.txt", + "skipped": true, + "checksum": candidate.checksum, + "sizeBytes": candidate.size_bytes, + }) + .to_string(); + let (base_url, fixture) = spawn_snapshot_fixture("200 OK", body); + let session = fixture_session(base_url); + let diff = ProjectSnapshotDiff { + uploads: vec![candidate], + ..ProjectSnapshotDiff::default() + }; + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + let request = fixture.join().expect("join fixture"); + + assert!(report.failures.is_empty(), "{:?}", report.failures); + assert_eq!(report.remote_skipped_files, 1); + assert_eq!(report.uploaded_bytes, 5); + assert_eq!( + report.uploaded_paths, + BTreeSet::from(["game/a.txt".to_string()]) + ); + assert!(request.starts_with("POST /api/agc/project-snapshots/files?")); + assert!(request.contains("projectId=project-1")); + assert!(request.contains("relativePath=game%2Fa.txt")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer fixture-token")); + assert!(request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc")); + assert!(request.ends_with("alpha")); +} + +#[test] +fn project_snapshot_file_matches_detects_metadata_drift() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let path = root.path().join("game/a.txt"); + let metadata = fs::symlink_metadata(&path).expect("fixture metadata"); + let size = metadata.len(); + let modified = project_snapshot_modified_ms(&metadata); + + assert!(project_snapshot_file_matches(&path, size, modified)); + assert!( + !project_snapshot_file_matches(&path, size + 1, modified), + "字节数变化必须被判为已改写" + ); + assert!( + !project_snapshot_file_matches(&path, size, modified + 1), + "修改时间变化必须被判为已改写" + ); + assert!( + !project_snapshot_file_matches(&root.path().join("game/missing.txt"), size, modified), + "文件消失必须被判为已改写" + ); +} + +#[test] +fn project_snapshot_diff_defers_files_that_changed_while_being_read() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let mut previous = BTreeMap::new(); + previous.insert( + "game/a.txt".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 5, + modified_ms: 42, + checksum: "fnv1a64:00000000000000aa".to_string(), + }, + ); + // 扫描时刻的元数据与磁盘现状不一致:等价于"扫描之后文件被改写"。 + let scan = ProjectSnapshotScanResult { + files: vec![ProjectSnapshotScannedFile { + relative_path: "game/a.txt".to_string(), + size_bytes: 5, + modified_ms: 41, + absolute_path: root.path().join("game/a.txt"), + }], + skipped: Vec::new(), + }; + + let diff = compute_project_snapshot_diff(&scan, &previous, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff"); + assert!(diff.uploads.is_empty(), "{:?}", diff.uploads); + assert_eq!(diff.pending.len(), 1); + assert_eq!(diff.pending[0].relative_path, "game/a.txt"); + assert!( + diff.deleted.is_empty(), + "同步期间被改写的文件不能被当成删除,否则远端 GC 会误删" + ); + assert!( + diff.current.contains_key("game/a.txt"), + "应沿用上一轮记录,保持清单与远端对象一致" + ); +} + +#[test] +fn project_snapshot_upload_skips_files_that_changed_after_the_diff() { + let root = fixture_root(); + let mut candidate = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + candidate.modified_ms = candidate.modified_ms.saturating_sub(1_000); + // 服务地址故意指向未监听端口:只要请求真的发出去就会出现 transport 失败, + // 因此这里同时证明"没有发出请求"和"分类是 file-changed"。 + let session = fixture_session("http://127.0.0.1:9".to_string()); + let diff = ProjectSnapshotDiff { + uploads: vec![candidate], + ..ProjectSnapshotDiff::default() + }; + + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + assert!(report.uploaded_paths.is_empty()); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].code, "file-changed"); + assert!( + ProjectSnapshotUploadErrorKind::FileChanged.is_retryable(), + "文件被改写不是终态失败,下一次触发应重试" + ); +} + +#[test] +fn project_snapshot_upload_stops_after_a_deterministic_authentication_failure() { + let root = fixture_root(); + let first = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + let second = fixture_candidate(root.path(), "game/b.txt", b"beta!"); + let (base_url, fixture) = spawn_snapshot_fixture("401 Unauthorized", "{}".to_string()); + let session = fixture_session(base_url); + let diff = ProjectSnapshotDiff { + uploads: vec![first, second], + ..ProjectSnapshotDiff::default() + }; + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + fixture.join().expect("join fixture"); + + assert!(report.uploaded_paths.is_empty()); + assert_eq!(report.failures.len(), 2); + assert_eq!(report.failures[0].code, "authentication-required"); + assert_eq!(report.failures[0].relative_path, "game/a.txt"); + assert_eq!(report.failures[1].relative_path, "game/b.txt"); + assert!( + !ProjectSnapshotUploadErrorKind::Authentication.is_retryable(), + "鉴权失败不能进入自动重试" + ); +} + +#[test] +fn project_snapshot_project_path_is_read_from_window_urls_only() { + let url = url::Url::parse("tauri://localhost/index.html?main&projectPath=C%3A%5Cgames%5Cdemo") + .expect("parse fixture url"); + assert_eq!( + project_snapshot_project_path_from_url(&url), + Some("C:\\games\\demo".to_string()) + ); + let launcher = url::Url::parse("tauri://localhost/index.html?launcher").expect("parse url"); + assert_eq!(project_snapshot_project_path_from_url(&launcher), None); +} + +/// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。 +/// +/// 默认忽略;需要显式提供目标项目与登录态: +/// +/// ```text +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT="<项目绝对路径>" +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN="" +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_API_BASE_URL="http://127.0.0.1:8082" +/// cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ +/// project_snapshot_live_sync -- --ignored --nocapture +/// ``` +/// +/// 判定口径:第一次必须 `synced` 且上传数大于 0;紧接着的第二次必须 `no-op` 且上传 0 个文件, +/// 用来证明差异对比在真实服务端与真实 OSS 上确实避免了重复上传。 +/// +/// 另需 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR` 指定 AppData 配置目录, +/// 否则测试进程里没有窗口 setup 初始化过该目录,索引无处可写。 +#[test] +#[ignore = "live smoke:需要真实 api-server 与 OSS 凭据"] +fn project_snapshot_live_sync_uploads_once_then_reports_no_op() { + let Ok(project_path) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT"); + return; + }; + let Ok(access_token) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN"); + return; + }; + let api_base_url = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_API_BASE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8082".to_string()); + let user_id = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_USER_ID") + .unwrap_or_else(|_| "snapshot-live-smoke-user".to_string()); + let Ok(config_dir) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR"); + return; + }; + set_game_creator_runtime_config_dir(PathBuf::from(config_dir.trim())); + + let _session = install_test_platform_session(&user_id, &access_token, &api_base_url); + let root = PathBuf::from(project_path.trim()); + let first = tauri::async_runtime::block_on(sync_project_snapshot_async( + &root, + ProjectSnapshotSyncTrigger::Manual, + )) + .expect("第一次项目快照同步必须成功"); + println!("第一次同步:{first:#?}"); + assert_eq!(first.status, "synced", "{first:#?}"); + assert!(first.uploaded_files > 0, "{first:#?}"); + assert!(first.failed_files.is_empty(), "{first:#?}"); + + let second = tauri::async_runtime::block_on(sync_project_snapshot_async( + &root, + ProjectSnapshotSyncTrigger::Manual, + )) + .expect("第二次项目快照同步必须成功"); + println!("第二次同步:{second:#?}"); + assert_eq!(second.status, "no-op", "{second:#?}"); + assert_eq!(second.uploaded_files, 0, "{second:#?}"); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs new file mode 100644 index 000000000..6ff4c226a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -0,0 +1,337 @@ +use super::*; +use std::collections::BTreeSet; + +/// 上传失败的分类。鉴权与权限类失败不重试,其余交给下一次周期或关闭触发。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectSnapshotUploadErrorKind { + Authentication, + Permission, + /// 同步期间文件被改写:本轮跳过该文件,下一次重算即可,不是终态失败。 + FileChanged, + /// 服务端按配额或频率拒绝:等下一次触发再试。 + Throttled, + Rejected, + Upstream, + Transport, +} + +impl ProjectSnapshotUploadErrorKind { + pub(crate) fn code(self) -> &'static str { + match self { + Self::Authentication => "authentication-required", + Self::Permission => "permission-denied", + Self::FileChanged => "file-changed", + Self::Throttled => "throttled", + Self::Rejected => "request-rejected", + Self::Upstream => "upstream-failed", + Self::Transport => "transport-failed", + } + } + + /// 鉴权、权限与请求被拒都是确定性失败;重复提交同样的内容不会变好。 + pub(crate) fn is_retryable(self) -> bool { + matches!( + self, + Self::Upstream | Self::Transport | Self::FileChanged | Self::Throttled + ) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ProjectSnapshotUploadError { + kind: ProjectSnapshotUploadErrorKind, + detail: String, +} + +impl ProjectSnapshotUploadError { + fn new(kind: ProjectSnapshotUploadErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } + + pub(crate) fn kind(&self) -> ProjectSnapshotUploadErrorKind { + self.kind + } + + pub(crate) fn code(&self) -> &'static str { + self.kind.code() + } + + pub(crate) fn message(&self) -> String { + format!("{}: {}", self.kind.code(), self.detail) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadFailure { + pub(crate) relative_path: String, + pub(crate) code: String, + pub(crate) detail: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadReport { + pub(crate) uploaded_paths: BTreeSet, + pub(crate) uploaded_bytes: u64, + pub(crate) remote_skipped_files: usize, + pub(crate) failures: Vec, +} + +impl ProjectSnapshotUploadReport { + fn from_hard_failure(paths: &[String], error: &ProjectSnapshotUploadError) -> Self { + Self { + uploaded_paths: BTreeSet::new(), + uploaded_bytes: 0, + remote_skipped_files: 0, + failures: paths + .iter() + .map(|relative_path| ProjectSnapshotUploadFailure { + relative_path: relative_path.clone(), + code: error.code().to_string(), + detail: error.detail.clone(), + }) + .collect(), + } + } +} + +/// 逐文件上传本次差异集合。单个文件失败只影响该文件;鉴权或权限失败会中止 +/// 后续请求,因为同样的凭据不会在这一次同步里变好。 +pub(crate) async fn upload_project_snapshot_diff( + session: &PlatformSessionSnapshot, + project_id: &str, + diff: &ProjectSnapshotDiff, +) -> ProjectSnapshotUploadReport { + let mut report = ProjectSnapshotUploadReport::default(); + if diff.uploads.is_empty() { + return report; + } + let client = match crate::http_client::agc_main_site_client_builder() + .timeout(Duration::from_secs( + PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS, + )) + .build() + { + Ok(client) => client, + Err(error) => { + let failure = ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("创建项目快照上传客户端失败:{error}"), + ); + let paths = diff + .uploads + .iter() + .map(|candidate| candidate.relative_path.clone()) + .collect::>(); + return ProjectSnapshotUploadReport::from_hard_failure(&paths, &failure); + } + }; + + for (index, candidate) in diff.uploads.iter().enumerate() { + match upload_project_snapshot_file(&client, session, project_id, candidate).await { + Ok(skipped_remote) => { + report + .uploaded_paths + .insert(candidate.relative_path.clone()); + report.uploaded_bytes = report.uploaded_bytes.saturating_add(candidate.size_bytes); + if skipped_remote { + report.remote_skipped_files += 1; + } + } + Err(error) => { + let fatal = !error.kind().is_retryable(); + report.failures.push(ProjectSnapshotUploadFailure { + relative_path: candidate.relative_path.clone(), + code: error.code().to_string(), + detail: error.detail.clone(), + }); + if fatal { + for remaining in diff.uploads.iter().skip(index + 1) { + report.failures.push(ProjectSnapshotUploadFailure { + relative_path: remaining.relative_path.clone(), + code: error.code().to_string(), + detail: "上一次请求已确定性失败,本轮不再继续".to_string(), + }); + } + break; + } + } + } + } + report +} + +/// 返回是否为"远端已存在同内容对象"的跳过。 +async fn upload_project_snapshot_file( + client: &reqwest::Client, + session: &PlatformSessionSnapshot, + project_id: &str, + candidate: &ProjectSnapshotUploadCandidate, +) -> Result { + let bytes = read_project_snapshot_file_bytes(&candidate.absolute_path).map_err(|error| { + ProjectSnapshotUploadError::new(ProjectSnapshotUploadErrorKind::Rejected, error) + })?; + if bytes.len() as u64 != candidate.size_bytes { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::FileChanged, + format!( + "项目快照文件在同步期间发生变化:{}", + candidate.relative_path + ), + )); + } + // 上传前再复核一次元数据:只比长度会漏掉"改完又改回同样大小"的情况, + // 这种文件本轮不传,也不进入索引。 + if !project_snapshot_file_matches( + &candidate.absolute_path, + candidate.size_bytes, + candidate.modified_ms, + ) { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::FileChanged, + format!( + "项目快照文件在同步期间发生变化:{}", + candidate.relative_path + ), + )); + } + let url = project_snapshot_endpoint( + session, + PROJECT_SNAPSHOT_FILES_ENDPOINT, + [ + ("projectId", project_id.to_string()), + ("relativePath", candidate.relative_path.clone()), + ("checksum", candidate.checksum.clone()), + ("sizeBytes", candidate.size_bytes.to_string()), + ], + )?; + let request = client + .post(url) + .bearer_auth(&session.access_token) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(bytes); + let response = crate::http_client::with_agc_main_site_marker(request) + .send() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照上传请求失败:{error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(project_snapshot_response_error(status, response).await); + } + let parsed = response + .json::() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Upstream, + format!("项目快照上传响应无法解析:{error}"), + ) + })?; + if parsed.relative_path != candidate.relative_path || parsed.checksum != candidate.checksum { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Upstream, + format!("项目快照上传响应与请求不一致:{}", candidate.relative_path), + )); + } + Ok(parsed.skipped) +} + +/// 提交本次同步的远端清单;删除只在这里表达。 +pub(crate) async fn upload_project_snapshot_manifest( + session: &PlatformSessionSnapshot, + payload: &shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestRequest, +) -> Result<(), ProjectSnapshotUploadError> { + let client = crate::http_client::agc_main_site_client_builder() + .timeout(Duration::from_secs( + PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS, + )) + .build() + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("创建项目快照上传客户端失败:{error}"), + ) + })?; + let url = project_snapshot_endpoint(session, PROJECT_SNAPSHOT_MANIFEST_ENDPOINT, [])?; + let request = client + .post(url) + .bearer_auth(&session.access_token) + .json(payload); + let response = crate::http_client::with_agc_main_site_marker(request) + .send() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照清单上传请求失败:{error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(project_snapshot_response_error(status, response).await); + } + Ok(()) +} + +fn project_snapshot_endpoint( + session: &PlatformSessionSnapshot, + path: &str, + query: impl IntoIterator, +) -> Result { + let base_url = session.api_base_url.trim_end_matches('/'); + if base_url.is_empty() { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Authentication, + "登录态缺少 API Server 地址", + )); + } + let mut url = reqwest::Url::parse(&format!("{base_url}{path}")).map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照接口地址无效:{error}"), + ) + })?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, &value); + } + } + Ok(url) +} + +async fn project_snapshot_response_error( + status: reqwest::StatusCode, + response: reqwest::Response, +) -> ProjectSnapshotUploadError { + let kind = match status.as_u16() { + 401 => ProjectSnapshotUploadErrorKind::Authentication, + 403 => ProjectSnapshotUploadErrorKind::Permission, + // 服务端配额与频率闸门:等下一次触发,不做本轮内重试。 + 429 => ProjectSnapshotUploadErrorKind::Throttled, + code if (400..500).contains(&code) => ProjectSnapshotUploadErrorKind::Rejected, + _ => ProjectSnapshotUploadErrorKind::Upstream, + }; + // 上游正文可能带内部信息,只保留状态码与有界摘要。 + let detail = response + .text() + .await + .map(|body| body.chars().take(200).collect::()) + .unwrap_or_default(); + let detail = detail.trim().to_string(); + if detail.is_empty() { + ProjectSnapshotUploadError::new(kind, format!("项目快照接口返回 HTTP {}", status.as_u16())) + } else { + ProjectSnapshotUploadError::new( + kind, + format!("项目快照接口返回 HTTP {}:{detail}", status.as_u16()), + ) + } +} diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 9b508dac1..eefb1ae55 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -162,6 +162,16 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS=600 ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520 ALIYUN_OSS_SUCCESS_ACTION_STATUS=200 +# AGC 项目定时快照上传目标。对象只落在服务端私有前缀 +# agc/project-snapshots/v1/{user}/{project}/ 下;AccessKey 为空时回退 ALIYUN_OSS_ACCESS_KEY_*, +# 因此回退凭据必须对目标 bucket 具备该前缀的 PutObject/GetObject/DeleteObject 权限。 +# bucket 未单独配置时默认 agc-dev,未配置凭据时 api-server 跳过该客户端, +# 接口返回 503 且客户端失败关闭(不写空对象、不推进本地索引)。 +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= + # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups diff --git a/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md new file mode 100644 index 000000000..3fe5a8630 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC项目定时快照上传-2026-09-17.md @@ -0,0 +1,43 @@ +# AGC 项目定时快照上传实施计划 + +Version: 1.0 +Status: active +Date: 2026-09-17 +Parent Milestone: `【里程碑】AGC项目定时快照上传-2026-09-17.md` + +## 修改边界 + +1. `server-rs/crates/shared-contracts/src/`:新增 `agc_project_snapshots` DTO(单文件上传请求/响应、同步清单信封),只放共享字段,不放 OSS 细节。 +2. `apps/ai-game-creator-shell/src-tauri/src/project_snapshot/`:新增客户端模块,包含扫描与排除规则、索引读写、差异对比、上传编排、状态与日志;不修改 `project/` 下既有 manifest 与写锁语义。 +3. `apps/ai-game-creator-shell/src-tauri/src/main.rs`:注册新模块、命令与生命周期钩子;`windows.rs` 的窗口关闭与应用退出路径接入触发调用,不改变现有窗口创建/关闭顺序。 +4. `server-rs/crates/platform-oss/src/lib.rs`:新增项目快照私有前缀常量与(必要时)独立 bucket 配置入口;不改动既有前缀枚举语义与资源写路径。 +5. `server-rs/crates/api-server/src/project_snapshots.rs`:新增路由、鉴权、校验与 OSS 写入;不改动 `error_reports` 与 `assets` 既有路由。 +6. `.env.example`:补充 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 说明与默认值。 +7. `docs/`:主规范已更新;完成后把持久结论合并回主规范并删除本计划。 + +## 实现顺序 + +1. 先写 `shared-contracts` DTO 与客户端差异引擎(扫描、排除、索引、diff)及单测,此时无网络依赖,可独立验证。 +2. 接上传编排:按差异集合逐文件提交,成功后再提交清单,最后推进索引;用本地 TCP stub server 覆盖成功、幂等跳过、鉴权失败与部分失败路径。 +3. 接触发接线:周期定时器、工作区窗口关闭与应用退出;确认关闭路径的有界超时和串行化。 +4. 最后接服务端路由与 OSS 写入,补参数校验与幂等跳过测试;服务端完成前客户端按"未配置即失败关闭、不写入索引"处理。 + +每一步都保留既有失败关闭行为;新模块默认不改变其它同步路径(Runner、项目写锁、Resource Editor)。 + +## 验证命令 + +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot -- --nocapture` +- `cargo test -p api-server --bin api-server project_snapshots -- --nocapture` +- `cargo fmt -p api-server -p shared-contracts -p platform-oss -- --check` +- `npm run --prefix apps/ai-game-creator-shell typecheck`(若触及前端) +- `npm run check:encoding` +- `git diff --check` +- 运行时按需:`npm run agc` 打开项目观察索引写入与同步日志,关闭窗口确认关闭触发。 + +## 风险与回滚 + +- 上传体积与带宽:首轮全量可能很大,先设单文件与单次同步总量上限并把超限项记入跳过清单;不静默截断。 +- 数据出境边界:只上传项目目录内普通文件,排除 `.agent/runtime`、`.agent/logs`、`.git`、构建产物与临时文件;凭据类文件不在白名单内。 +- 服务端未配置 bucket 时客户端必须失败关闭,不能把本地索引推进成"已同步",否则后续同步会漏传。 +- 回滚:客户端可停用触发接线(保留模块与测试)即可回到无上传行为;服务端路由与配置项可单独移除,不影响既有 OSS 前缀与错误报告链路。 diff --git a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md new file mode 100644 index 000000000..25f0c802e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md @@ -0,0 +1,49 @@ +# AGC 项目定时快照上传 + +Version: 1.0 +Status: active +Date: 2026-09-17 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-17 AGC 项目定时快照上传(agc-dev)” + +## 目标 + +AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;只上传内容发生变化的文件,重复内容不重复上传,远端缺少对应对象时才新建。 + +## 范围 + +- 客户端 `src-tauri/src/project_snapshot/`(`scan.rs` / `diff.rs` / `index.rs` / `transport.rs`):项目扫描、排除规则、增量索引与差异对比、上传编排、状态查询。 +- 触发接线:工作区窗口存活周期定时器、工作区窗口关闭(`CloseRequested`);应用退出只做有界等待,不重复发起同步。 +- 服务端 `POST /api/agc/project-snapshots/files` 与 `POST /api/agc/project-snapshots/manifest`:登录态鉴权、参数校验、私有前缀 OSS 写入、HEAD 幂等跳过。 +- 目标 bucket 配置:`GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*`,默认 `agc-dev`。 +- 契约:`shared-contracts::agc_project_snapshots` 新增请求/响应 DTO 与项目 ID、相对路径、摘要校验函数。 +- 客户端增量索引:`/project-snapshots//index.json`,按用户身份判等,换号后按冷启动全量重算。 +- 排除口径:复用 `should_skip_project_snapshot_path`(整个 `.agent`、`.git`、构建与依赖目录、凭据目录、敏感后缀、符号链接与重解析点)。 + +## 不做 + +- 不做云端下载/恢复、跨设备合并、版本回滚。 +- 不保留多版本历史:清单写入成功后回收不再被引用的旧对象,同一路径只保留当前内容。 +- 不下发 bucket 生命周期策略;不做跨节点的用户级总量配额与计费口径。 +- 不新增 SpacetimeDB 表或 procedure,不修改 `/api/external/v1` 与 External OpenAPI。 +- 不在客户端暴露上传状态、时间线或入口按钮;状态只落本机诊断日志,排障走 native-only 命令。 + +## 验收标准 + +1. 首次同步上传项目内全部符合条件的普通文件;再次同步在无改动时上传 0 个文件。 +2. 只修改一个文件时,差异集合恰好包含一个修改项;删除一个文件时上传集合为空且清单中不再包含该文件。 +3. `(字节数, 修改时间)` 未变的文件复用已存摘要,不重复读取内容计算摘要。 +4. 排除规则命中项(`.agent/runtime`、`.agent/logs`、`.git`、`node_modules`、构建产物、临时文件、符号链接)与超限文件进入跳过清单,不进入上传集合。 +5. 任一次同步失败(非鉴权类)不推进本地索引,下一次触发重算并重试;鉴权/权限类失败不自动重试。 +6. 同一项目的并发触发串行执行,不产生两路重复上传。 +7. 工作区窗口关闭与应用退出都会触发一次同步,且关闭路径不因同步失败而阻塞退出超过超时上限。 +8. 服务端拒绝越界 `projectId`、相对路径与摘要;相同摘要重复提交走跳过分支且不写入新对象。 +9. 新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。 +10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。 +11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`。 +12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。 + +## 依赖 + +- 现有 `platform_session`(用户身份与 Access Token)、项目 manifest(稳定 `project_id`)。 +- 现有 `platform-oss`(PUT/HEAD、私有访问)、`api-server` 登录态中间件与 `shared-contracts`。 +- 现有 AppData 私有文件写入与目录解析工具。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0828b8fda..550e75f0c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1530,3 +1530,56 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 两个窗口同时对同一项目发起 Runtime 写请求时,用户体验仍由项目级写锁串行决定;本次不引入跨窗口排队提示。 - 平台会话在窗口间传播依赖共享 localStorage 与 Runner 权威;渲染层不做跨窗口事件推送,另一个窗口在下一次会话校验或刷新时收敛。 + +## 2026-09-17 AGC 项目定时快照上传(agc-dev) + +### 目标与非目标 + +- 目标:AGC 在项目工作区打开期间按固定周期把用户项目增量上传到 OSS `agc-dev`,并在项目关闭时立即补一次同步;重复内容不重复上传,远端占用跟随当前清单收敛。 +- 非目标:不做云端下载/恢复、不做跨设备合并、不保留多版本历史、不新增面向用户的上传界面、不修改 `/api/external/v1` 与 OpenAPI、不新增 SpacetimeDB 表。 +- 非目标:不把 OSS AccessKey 放进客户端;客户端不直连 OSS。 + +### 参与入口、状态与跨模块边界 + +- 触发入口有两个:工作区窗口 `main` 存活期间的周期定时器、工作区窗口关闭事件(`CloseRequested`)。两者共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。 +- 应用退出(`RunEvent::Exit`)不重复发起同步:该时刻窗口已销毁,按窗口重新枚举项目只会得到空集;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步能写完索引再退出。 +- 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。 +- 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 +- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。 +- 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。 +- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v1/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v1/{userId}/{projectId}/manifest.json`。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。 +- 目标 bucket 使用独立配置 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET` / `_ENDPOINT` / `_ACCESS_KEY_ID` / `_ACCESS_KEY_SECRET`,默认 `agc-dev` + `oss-rg-china-mainland.aliyuncs.com`,未配置时回退 `ALIYUN_OSS_*`;与"资源 bucket 与备份 bucket 分离"的既有口径一致。 + +### 正常、失败、重试与幂等行为 + +- 差异对比口径:先按 `相对路径 + 字节数 + 修改时间` 判定是否候选变更,命中旧记录则复用已存 `sha256`,只有 `(size, mtime)` 变化才重算摘要。产出新增、修改、删除三类集合,只上传新增与修改的文件。 +- 每次成功同步的最后一步上传该项目的 `manifest.json`(当前全量文件清单:相对路径、摘要、字节数、同步序号)。清单描述的是项目当前全量内容,因此清单体积就是该项目在 OSS 上的常驻占用。 +- 远端回收:清单写入成功后,服务端读取上一版清单,按 `(路径, 字节数, 摘要)` 反推出不再被当前清单引用的对象键并删除。只处理上一版清单登记过的键,不做 LIST,因此不可能误删其它项目或其它功能的对象;单次最多回收 2000 个对象,剩余部分留到下一次清单写入继续;上一版清单读不到或解析失败时整轮跳过回收(fail-closed)。单个删除失败只记日志,不影响本次同步语义。 +- 因此本功能是"当前状态镜像 + 清单",不保留历史版本:同一路径的内容变化会覆盖式替换远端对象,回滚能力不在本轮范围内。 +- 幂等:同一摘要与字节数的对象重复提交由服务端 HEAD 校验后跳过;探测失败按"未存在"处理并照常 PUT,宁可多传一次也不漏传。索引只在清单写入成功后推进,失败时保留旧索引以便下次重算。 +- 与项目写锁解耦:同步不持有项目写锁,也不阻塞 Agent 写入。读取摘要后与上传前各按 `(字节数, 修改时间)` 复核一次,任一处不一致就判定该文件"同步期间发生变化":本轮不上传、不写入索引;若该路径上一轮已同步过则沿用旧记录,避免被误判成删除而触发远端回收。这类文件在下一个周期或下次关窗时重算重试。 +- 失败关闭:单个文件失败不推进该文件的索引项,失败文件与剩余文件在下一次周期或下次关闭时重试。鉴权失败(401/403)与格式类拒绝(400/413)是确定性失败,停止本轮剩余请求并等待用户处理后重试;服务端配额或频率拒绝(429)与传输类失败按可重试处理。 +- 配额与限流:单文件 64 MiB、单次同步上传预算 512 MiB(超出部分延后到下一次)、单项目常驻上限 2 GiB(客户端在扫描后先判,超限直接给出明确失败;服务端按清单累计体积复核并返回 413);服务端按用户做进程内小时配额(文件 3000 次、清单 120 次)并对同一项目强制 5 秒最小清单间隔,超限返回 429 且带 `Retry-After`。进程内配额只用于抑制异常客户端与失控重试,跨节点配额由"单项目上限 + 清单引用回收"保证。 +- 生命周期:同步有界超时(单文件与整次同步分别设上限),项目关闭与应用退出路径不因同步失败而阻塞或延迟退出超过超时上限。 +- 上传内容边界:复用项目索引与 checkpoint 同一份 `should_skip_project_snapshot_path` 口径——整个 `.agent`(含 runtime、logs、checkpoint、manifest、project.lock)、版本控制目录、`node_modules`/`target`/`dist`/`build`/`coverage`/`.cache`、凭据目录与 `.pem`/`.key` 等敏感后缀都不参与同步;符号链接与重解析点同样跳过。单文件(64 MiB)与单次同步总量(512 MiB)各有上限,超限文件进入跳过或延后清单而不是静默丢弃。 + +### 契约与兼容 + +- 新增登录态内部路由 `POST /api/agc/project-snapshots/files`,请求 DTO 放在 `shared-contracts`;不属于 `/api/external/v1`,因此不更新 External OpenAPI,与 `/api/error-reports` 同类。 +- 服务端校验 `projectId` 形态(拒绝路径分隔符、`..`、控制字符与超长值)、相对路径规范(正斜杠、拒绝绝对路径与穿越)、摘要形态(`fnv1a64:` + 16 位十六进制)和字节数上限,任何越界返回 4xx 而不是写入 OSS。 +- 不改变客户端与 Runner 的本机协议、平台会话语义、项目写锁与 manifest 结构;新增索引文件位于 AppData,不进入用户项目目录。 + +### 验收标准与证据来源 + +- 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。 +- 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。 +- 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。 +- 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。 + +### 未决问题 + +- 用户侧看不到同步状态与失败原因(界面按产品口径不暴露),排障只能读 AppData 诊断日志或调用 native-only 命令;如果后续要支持用户自助排查,需要先确认是否允许在客户端出现上传相关 UI。 +- 历史版本:本轮只保留"当前状态镜像 + 清单",旧内容对象在清单写入成功后即被回收,没有回滚能力;要保留历史版本需要先定"保留几个 revision + 由谁回收"的策略。 +- 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。 +- 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。 +- 大项目(素材数量多、单文件大)的首轮全量上传耗时与带宽占用未实测;单次预算 512 MiB 会把超出部分留到下一次同步,但并发上限与断点续传仍未引入。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 38285c41a..d13920f03 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -540,6 +540,50 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 使用 `AppState` 内同一个 OSS HTTP Client/连接池,并受进程级 8 路 OSS permit 保护;BgFilter、阿里云抠图和本地处理不占用该 permit。每个 OSS attempt 最多 3 次(首次 + 2 次重试),退避为 250ms、500ms;只重试 timeout、无 HTTP 响应传输错误、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 500–599。动作帧 PUT 收到 400 时只读取最多 16 KiB OSS 错误 XML,提取 `Code` 和 `RequestId`;`oss_request_id` 优先使用响应头 `x-oss-request-id`,XML 字段只作回退。错误体读取超时/断流不再按确定性 400 处理:已解析出的 `Code` 优先生效;未解析出 `Code` 时按读取失败原因置 `timeout`/`transport` 并重试,message 追加「错误响应体读取失败」。日志字段包括 `frame_index`、`object_key`、`operation=source_put|final_put|final_head`、`attempt`、`max_attempts`、`retryable`、`will_retry`、`retry_delay_ms`、`permit_wait_ms`、`timeout`、`connect`、`transport`、`oss_code`、`oss_request_id`、`status` 和 `elapsed_ms`。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败;`status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」)同样是暂时性失败。除 `RequestTimeout` 和该错误体读取失败两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT;如果任一帧最终失败,确认整段动作已排空已启动 Future,并检查任务按现有契约退款且没有发布缺帧动画。 +### AGC 项目快照上传目标 + +AGC 客户端按周期与项目关闭时机把用户项目增量上传到 `agc-dev`。客户端只持有平台登录态 Access +Token,经 `POST /api/agc/project-snapshots/files`(单文件原始字节)与 +`POST /api/agc/project-snapshots/manifest`(本次同步清单)交给 `api-server`,由服务端写入私有前缀 +`agc/project-snapshots/v1/{user}/{project}/`;客户端不直连 OSS,也不持有 OSS 凭据。 + +```env +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET=agc-dev +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT=oss-rg-china-mainland.aliyuncs.com +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= +``` + +专用凭据为空时回退 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,bucket 与 endpoint +仍默认指向 AGC 发行 bucket,因此回退凭据必须具备目标 bucket 该前缀的 `PutObject` / `GetObject` / +`DeleteObject` 权限;`api-server` 启动时会打印一行 `AGC 项目快照 OSS 客户端已启用`(含 bucket、 +endpoint 与凭据来源,不含密钥),凭据缺失或只配一半则跳过该客户端、接口返回 `503`,客户端按失败关闭 +处理:不写空对象,也不推进本地增量索引,下一次触发重算重试。 + +远端占用按当前清单收敛:每次清单写入成功后,服务端用上一版清单反推不再被引用的对象键并删除,单次最多 +回收 2000 个,上一版清单不可读时整轮跳过(不会误删)。因此不需要额外配置 bucket 生命周期来防止无界 +增长;`agc/project-snapshots/v1/` 下同一路径只保留当前内容,历史版本不保留。配额口径:单文件 64 MiB、 +单次同步上传预算 512 MiB、单项目常驻 2 GiB;超限分别表现为跳过/延后/413。服务端另有进程内小时配额 +(文件 3000 次、清单 120 次)与同一项目 5 秒最小清单间隔,超限返回 `429` 并带 `Retry-After`。 + +两层可重复的现场验证: + +```bash +# 1. 存储层:直接对真实 bucket 做内部前缀写入、读回与清理,并在结束时删除探针对象。 +cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml + +# 2. 客户端链路:真实差异引擎 → 本地 api-server → 真实 OSS。第一次必须 synced 且上传 > 0, +# 紧接着的第二次必须 no-op 且上传 0 个文件;需要先取得登录态并指定项目与索引目录。 +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ + project_snapshot_live_sync -- --ignored --nocapture +``` + +客户端冒烟读取 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT`(项目绝对路径,建议用可丢弃的副本)、 +`..._LIVE_TOKEN`、`..._LIVE_API_BASE_URL`、`..._LIVE_USER_ID` 与 `..._LIVE_CONFIG_DIR`(索引目录, +`cargo test` 进程没有窗口 setup 初始化 AppData 配置目录,必须显式指定);未设置时用例自我跳过。 +本地联调可用 `npm run dev:api-server` 起 api-server,并用密码登录(开发态默认允许未知手机号自动注册) +取得 Access Token。 + ## 生产运维 生产部署当前口径: diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d4d081884..4c34bda86 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -51,6 +51,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::external_generation::router(state.clone())) .merge(modules::platform_support::router(state.clone())) .merge(modules::raw::router(state.clone())) + .merge(modules::project_snapshots::router(state.clone())) .merge(crate::error_reports::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 94ea4d4e4..6a50319a2 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -27,6 +27,9 @@ const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3; const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 120; const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com"; const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000; +/// AGC 项目快照默认落到 AGC 发行 bucket;私有前缀由 platform-oss 单独限制。 +const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET: &str = "agc-dev"; +const DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra"; const LLM_ROUTER_KEY_ENCRYPTION_DOMAIN: &[u8] = b"genarrative:llm-router-api-key-encryption:v1\0"; @@ -169,6 +172,12 @@ pub struct AppConfig { pub oss_post_expire_seconds: u64, pub oss_post_max_size_bytes: u64, pub oss_success_action_status: u16, + /// AGC 项目快照上传目标。默认指向 AGC 发行用的公开 bucket,可用独立凭据覆盖; + /// 未单独配置时回退 `ALIYUN_OSS_*`,与数据库备份 bucket 的分离开关同口径。 + pub project_snapshot_oss_bucket: String, + pub project_snapshot_oss_endpoint: String, + pub project_snapshot_oss_access_key_id: Option, + pub project_snapshot_oss_access_key_secret: Option, pub spacetime_server_url: String, pub spacetime_database: String, pub spacetime_token: Option, @@ -476,6 +485,10 @@ impl Default for AppConfig { oss_post_expire_seconds: 10 * 60, oss_post_max_size_bytes: 20 * 1024 * 1024, oss_success_action_status: 200, + project_snapshot_oss_bucket: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string(), + project_snapshot_oss_endpoint: DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string(), + project_snapshot_oss_access_key_id: None, + project_snapshot_oss_access_key_secret: None, spacetime_server_url: "http://127.0.0.1:3000".to_string(), spacetime_database: "genarrative-dev".to_string(), spacetime_token: None, @@ -1121,6 +1134,24 @@ impl AppConfig { { config.oss_success_action_status = oss_success_action_status; } + config.project_snapshot_oss_bucket = read_first_non_empty_env(&[ + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET", + "ALIYUN_OSS_BUCKET", + ]) + .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_BUCKET.to_string()); + config.project_snapshot_oss_endpoint = read_first_non_empty_env(&[ + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT", + "ALIYUN_OSS_ENDPOINT", + ]) + .unwrap_or_else(|| DEFAULT_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT.to_string()); + config.project_snapshot_oss_access_key_id = read_first_non_empty_env(&[ + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", + "ALIYUN_OSS_ACCESS_KEY_ID", + ]); + config.project_snapshot_oss_access_key_secret = read_first_non_empty_env(&[ + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", + "ALIYUN_OSS_ACCESS_KEY_SECRET", + ]); if let Some(spacetime_server_url) = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 25e42cd28..c0980823f 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -66,6 +66,7 @@ mod process_metrics; mod profile_identity; mod profile_recharge_expiration_listener; mod profile_recharge_refund_reconciliation; +mod project_snapshots; mod prompt; mod raw_image; mod refresh_session; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 76f5958b7..0b833ab21 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -10,4 +10,5 @@ pub mod internal; pub mod platform; pub mod platform_support; pub mod profile; +pub mod project_snapshots; pub mod raw; diff --git a/server-rs/crates/api-server/src/modules/project_snapshots.rs b/server-rs/crates/api-server/src/modules/project_snapshots.rs new file mode 100644 index 000000000..65683b00f --- /dev/null +++ b/server-rs/crates/api-server/src/modules/project_snapshots.rs @@ -0,0 +1,31 @@ +use axum::{Router, extract::DefaultBodyLimit, middleware, routing::post}; + +use crate::{ + auth::require_bearer_auth, + project_snapshots::{ + MAX_FILE_REQUEST_BODY_BYTES, MAX_MANIFEST_REQUEST_BODY_BYTES, upload_project_snapshot_file, + upload_project_snapshot_manifest, + }, + state::AppState, +}; + +/// AGC 项目快照只接受登录态客户端;两条路由都带体积门禁,超限请求在进入业务 +/// 处理前就被拒绝。 +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/agc/project-snapshots/files", + post(upload_project_snapshot_file) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(MAX_FILE_REQUEST_BODY_BYTES)), + ) + .route( + "/api/agc/project-snapshots/manifest", + post(upload_project_snapshot_manifest) + .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)) + .layer(DefaultBodyLimit::max(MAX_MANIFEST_REQUEST_BODY_BYTES)), + ) +} diff --git a/server-rs/crates/api-server/src/project_snapshots.rs b/server-rs/crates/api-server/src/project_snapshots.rs new file mode 100644 index 000000000..d745d7493 --- /dev/null +++ b/server-rs/crates/api-server/src/project_snapshots.rs @@ -0,0 +1,517 @@ +//! AGC 项目定时快照上传的服务端入口。 +//! +//! 客户端只提交"某个项目里发生变化的文件内容"和"当前清单",对象键、bucket 与 +//! 存储凭据都由服务端决定。写入固定在内部前缀下,客户端直传票据不覆盖该前缀。 + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; +use axum::{ + Json, + body::Bytes, + extract::{Extension, Query, State}, + http::StatusCode, +}; +use platform_oss::{ + OssDeleteObjectRequest, OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess, + agc_project_snapshot_file_object_key, agc_project_snapshot_manifest_object_key, +}; +use serde_json::Value; +use shared_contracts::agc_project_snapshots::{ + AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES, AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES, + AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES, AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, + AgcProjectSnapshotFileUploadQuery, AgcProjectSnapshotFileUploadResponse, + AgcProjectSnapshotManifestRequest, AgcProjectSnapshotManifestResponse, + validate_agc_project_snapshot_checksum, validate_agc_project_snapshot_project_id, + validate_agc_project_snapshot_relative_path, +}; +use std::{ + collections::{HashMap, HashSet}, + sync::{Mutex, OnceLock}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tracing::{debug, info, warn}; + +/// 单文件请求体上限比文件上限留一点余量,超限请求由 body limit 直接拒绝。 +pub(crate) const MAX_FILE_REQUEST_BODY_BYTES: usize = + AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES as usize + 1024; +/// 清单请求体上限:条目数本身有上限,这里再给一个字节级兜底。 +pub(crate) const MAX_MANIFEST_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024; +/// 清单里单条路径的字段长度上限(与契约校验口径一致)。 +const MAX_MANIFEST_PATH_CHARS: usize = 1024; +/// 单个用户每小时允许的文件上传次数。进程内计数,用于抑制异常客户端,不是计费级配额。 +const MAX_FILE_UPLOADS_PER_USER_PER_HOUR: usize = 3_000; +/// 单个用户每小时允许的清单写入次数。 +const MAX_MANIFEST_UPLOADS_PER_USER_PER_HOUR: usize = 120; +/// 同一项目两次清单写入的最小间隔,避免异常客户端高频覆盖清单。 +const MIN_MANIFEST_INTERVAL_MS: u64 = 5_000; +/// 单次清单写入最多回收多少个不再被引用的对象,避免一次请求做过量删除。 +const MAX_OBJECTS_RECLAIMED_PER_MANIFEST: usize = 2_000; + +/// 写入一次增量同步里的单个文件。 +/// +/// 对象键由字节数和内容摘要共同决定,因此"对象已存在且长度一致"就是内容已存在的 +/// 充分判据;探测失败按未存在处理,PUT 本身是幂等的。 +pub async fn upload_project_snapshot_file( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Query(query): Query, + body: Bytes, +) -> Result, AppError> { + consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::File)?; + validate_agc_project_snapshot_project_id(&query.project_id).map_err(bad_request)?; + validate_agc_project_snapshot_relative_path(&query.relative_path).map_err(bad_request)?; + validate_agc_project_snapshot_checksum(&query.checksum).map_err(bad_request)?; + if body.is_empty() { + return Err(bad_request("项目快照文件内容不能为空")); + } + let size_bytes = + u64::try_from(body.len()).map_err(|_| bad_request("项目快照文件长度超出可支持范围"))?; + if size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES { + return Err(bad_request("项目快照文件超过单文件上限")); + } + if size_bytes != query.size_bytes { + return Err(bad_request("项目快照文件长度与声明不一致")); + } + let digest = query + .checksum + .strip_prefix("fnv1a64:") + .unwrap_or_default() + .to_string(); + let object_key = agc_project_snapshot_file_object_key( + auth.claims().user_id(), + &query.project_id, + size_bytes, + &digest, + &query.relative_path, + ) + .map_err(|error| bad_request(error.to_string()))?; + + let oss = project_snapshot_oss(&state)?; + let skipped = match oss + .head_internal_object(state.editor_oss_http_client(), &object_key) + .await + { + Ok(Some(existing)) => existing.content_length == size_bytes, + // 确定不存在、或探测失败时都继续写入:PUT 幂等,宁可多传一次也不漏传。 + Ok(None) | Err(_) => false, + }; + if !skipped { + oss.put_internal_object( + state.editor_oss_http_client(), + OssInternalPutObjectRequest { + object_key: object_key.clone(), + content_type: Some("application/octet-stream".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body: body.to_vec(), + }, + ) + .await + .map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照文件上传失败") + })?; + } + + Ok(json_success_body( + Some(&ctx), + AgcProjectSnapshotFileUploadResponse { + project_id: query.project_id, + relative_path: query.relative_path, + object_key, + skipped, + checksum: query.checksum, + size_bytes, + }, + )) +} + +/// 覆盖写入该项目的远端清单。删除文件只在这里消失,本期不删除远端对象。 +pub async fn upload_project_snapshot_manifest( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Json(payload): Json, +) -> Result, AppError> { + consume_user_upload_quota(auth.claims().user_id(), ProjectSnapshotUploadKind::Manifest)?; + validate_manifest(&payload)?; + let user_id = auth.claims().user_id().to_string(); + let object_key = agc_project_snapshot_manifest_object_key(&user_id, &payload.project_id) + .map_err(|error| bad_request(error.to_string()))?; + // 上一版清单同时承担两个职责:项目级写入频率闸门,以及本轮远端对象回收的引用基线。 + // 读不到或解析失败时只跳过回收,绝不据此删除任何对象。 + let previous = read_project_snapshot_manifest(&state, &object_key).await; + if let Some(previous) = previous.as_ref() + && unix_millis_now().saturating_sub(previous.synced_at_ms) < MIN_MANIFEST_INTERVAL_MS + { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message("同一项目的项目快照写入过于频繁,请稍后重试") + .with_header("retry-after", axum::http::HeaderValue::from_static("5"))); + } + let body = serde_json::to_vec(&payload) + .map_err(|error| internal(format!("序列化项目快照清单失败:{error}")))?; + let total_bytes = payload + .files + .iter() + .fold(0_u64, |total, file| total.saturating_add(file.size_bytes)); + let oss = project_snapshot_oss(&state)?; + oss.put_internal_object( + state.editor_oss_http_client(), + OssInternalPutObjectRequest { + object_key: object_key.clone(), + content_type: Some("application/json".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body, + }, + ) + .await + .map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("项目快照清单上传失败") + })?; + // 清单写入成功之后再回收:任何时刻远端对象集合都是当前清单的超集, + // 不会出现清单引用了刚被删掉的对象。 + reclaim_unreferenced_objects(&state, oss, &user_id, previous.as_ref(), &payload).await; + + Ok(json_success_body( + Some(&ctx), + AgcProjectSnapshotManifestResponse { + project_id: payload.project_id, + sync_revision: payload.sync_revision, + object_key, + file_count: u32::try_from(payload.files.len()).unwrap_or(u32::MAX), + total_bytes, + }, + )) +} + +/// 单个用户在窗口内的上传计数。进程内计数,用于抑制异常客户端与失控重试; +/// 跨节点配额由"单项目累计体积上限 + 清单引用回收"保证。 +#[derive(Clone, Copy, Default)] +struct ProjectSnapshotUserWindow { + started_at: Option, + files: usize, + manifests: usize, +} + +#[derive(Clone, Copy)] +enum ProjectSnapshotUploadKind { + File, + Manifest, +} + +static PROJECT_SNAPSHOT_USER_WINDOWS: OnceLock>> = + OnceLock::new(); + +fn consume_user_upload_quota( + user_id: &str, + kind: ProjectSnapshotUploadKind, +) -> Result<(), AppError> { + let windows = PROJECT_SNAPSHOT_USER_WINDOWS.get_or_init(|| Mutex::new(HashMap::new())); + let mut windows = windows + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let window = windows.entry(user_id.to_string()).or_default(); + let now = Instant::now(); + let expired = window + .started_at + .is_none_or(|started_at| now.duration_since(started_at) >= Duration::from_secs(3_600)); + if expired { + *window = ProjectSnapshotUserWindow { + started_at: Some(now), + ..ProjectSnapshotUserWindow::default() + }; + } + let (counter, limit) = match kind { + ProjectSnapshotUploadKind::File => (&mut window.files, MAX_FILE_UPLOADS_PER_USER_PER_HOUR), + ProjectSnapshotUploadKind::Manifest => ( + &mut window.manifests, + MAX_MANIFEST_UPLOADS_PER_USER_PER_HOUR, + ), + }; + if *counter >= limit { + return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message("项目快照上传次数超出当前小时上限,请稍后重试") + .with_header("retry-after", axum::http::HeaderValue::from_static("60"))); + } + *counter += 1; + Ok(()) +} + +fn unix_millis_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +/// 读取该项目上一版清单。只在对象不存在时返回 `None`;读取或解析失败同样返回 +/// `None`,调用方据此跳过回收(fail-closed:不确定就不删)。 +async fn read_project_snapshot_manifest( + state: &AppState, + object_key: &str, +) -> Option { + let oss = project_snapshot_oss(state).ok()?; + match oss + .get_object( + state.editor_oss_http_client(), + OssGetObjectRequest { + object_key: object_key.to_string(), + max_bytes: MAX_MANIFEST_REQUEST_BODY_BYTES, + }, + ) + .await + { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|error| { + warn!(object_key = %object_key, error = %error, "项目快照上一版清单解析失败,本轮跳过回收"); + error + }) + .ok(), + Err(error) => { + debug!(object_key = %object_key, error = %error, "项目快照上一版清单不可读,本轮跳过回收"); + None + } + } +} + +/// 回收不再被当前清单引用、且确实由上一版清单登记过的对象。 +/// +/// 只按上一版清单里的 `(路径, 字节数, 摘要)` 反推出确定的对象键,不做 LIST, +/// 因此不可能误删其它项目或其它功能的对象。任何单个删除失败都只记日志: +/// 清单已经写入成功,回收失败不影响本次同步的语义,下一次写入会再试。 +async fn reclaim_unreferenced_objects( + state: &AppState, + oss: &platform_oss::OssClient, + user_id: &str, + previous: Option<&AgcProjectSnapshotManifestRequest>, + next: &AgcProjectSnapshotManifestRequest, +) { + let Some(previous) = previous else { + return; + }; + let retained = next + .files + .iter() + .map(|file| { + ( + file.relative_path.as_str(), + file.size_bytes, + file.checksum.as_str(), + ) + }) + .collect::>(); + let mut reclaimed = 0_usize; + let mut skipped = 0_usize; + for file in &previous.files { + if retained.contains(&( + file.relative_path.as_str(), + file.size_bytes, + file.checksum.as_str(), + )) { + continue; + } + if reclaimed >= MAX_OBJECTS_RECLAIMED_PER_MANIFEST { + // 剩下的留给下一次清单写入继续回收,不在这里无限删除。 + skipped += 1; + continue; + } + let Some(digest) = file.checksum.strip_prefix("fnv1a64:") else { + continue; + }; + let Ok(object_key) = agc_project_snapshot_file_object_key( + user_id, + &previous.project_id, + file.size_bytes, + digest, + &file.relative_path, + ) else { + continue; + }; + match oss + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + Ok(()) => reclaimed += 1, + Err(error) => { + warn!(object_key = %object_key, error = %error, "项目快照旧版本对象回收失败"); + } + } + } + if reclaimed > 0 || skipped > 0 { + info!( + project_id = %previous.project_id, + reclaimed, + skipped, + "项目快照旧版本对象回收完成" + ); + } +} + +fn validate_manifest(payload: &AgcProjectSnapshotManifestRequest) -> Result<(), AppError> { + if payload.schema_version != AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION { + return Err(bad_request("项目快照清单版本不受支持")); + } + validate_agc_project_snapshot_project_id(&payload.project_id).map_err(bad_request)?; + if payload.synced_at_ms == 0 { + return Err(bad_request("项目快照清单缺少同步时刻")); + } + if payload.files.len() > AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES { + return Err(bad_request("项目快照清单文件数量超过上限")); + } + let mut seen = HashSet::with_capacity(payload.files.len()); + let mut total_bytes = 0_u64; + for file in &payload.files { + if file.relative_path.chars().count() > MAX_MANIFEST_PATH_CHARS { + return Err(bad_request("项目快照清单路径过长")); + } + validate_agc_project_snapshot_relative_path(&file.relative_path).map_err(bad_request)?; + validate_agc_project_snapshot_checksum(&file.checksum).map_err(bad_request)?; + if file.size_bytes > AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES { + return Err(bad_request("项目快照清单包含超过单文件上限的条目")); + } + if !seen.insert(file.relative_path.as_str()) { + return Err(bad_request("项目快照清单包含重复路径")); + } + total_bytes = total_bytes.saturating_add(file.size_bytes); + } + // 清单描述的是项目当前全量文件,所以这里的累计体积就是该项目的常驻占用; + // 单项目上限同时约束了远端对象回收之后的实际存储量。 + if total_bytes > AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES { + return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_message("项目快照累计体积超过单项目上限")); + } + Ok(()) +} + +fn project_snapshot_oss(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("AGC 项目快照 OSS 未配置") + }) +} + +fn bad_request(m: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(m) +} + +fn internal(e: E) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestFile; + + fn manifest_file(relative_path: &str, size_bytes: u64) -> AgcProjectSnapshotManifestFile { + AgcProjectSnapshotManifestFile { + relative_path: relative_path.to_string(), + size_bytes, + checksum: "fnv1a64:0123456789abcdef".to_string(), + } + } + + fn manifest(files: Vec) -> AgcProjectSnapshotManifestRequest { + AgcProjectSnapshotManifestRequest { + schema_version: AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, + project_id: "gameagent-1a2b3c4d".to_string(), + sync_revision: 1, + synced_at_ms: 1_700_000_000_000, + files, + } + } + + #[test] + fn project_snapshot_manifest_accepts_a_bounded_payload() { + assert!( + validate_manifest(&manifest(vec![ + manifest_file("game/index.html", 1024), + manifest_file("assets/Hero.png", 2048), + ])) + .is_ok() + ); + } + + #[test] + fn project_snapshot_manifest_rejects_duplicates_and_out_of_range_values() { + assert!( + validate_manifest(&manifest(vec![ + manifest_file("game/index.html", 1), + manifest_file("game/index.html", 1), + ])) + .is_err(), + "重复路径会让远端清单产生歧义" + ); + + let mut traversal = manifest(vec![manifest_file("game/index.html", 1)]); + traversal.files[0].relative_path = "../outside.txt".to_string(); + assert!(validate_manifest(&traversal).is_err()); + + let mut unsafe_project = manifest(vec![manifest_file("game/index.html", 1)]); + unsafe_project.project_id = "../escape".to_string(); + assert!(validate_manifest(&unsafe_project).is_err()); + + let mut bad_checksum = manifest(vec![manifest_file("game/index.html", 1)]); + bad_checksum.files[0].checksum = "md5:not-hex".to_string(); + assert!(validate_manifest(&bad_checksum).is_err()); + + let mut oversized = manifest(vec![manifest_file("game/index.html", 1)]); + oversized.files[0].size_bytes = AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES + 1; + assert!(validate_manifest(&oversized).is_err()); + + let mut stale_schema = manifest(vec![manifest_file("game/index.html", 1)]); + stale_schema.schema_version = AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION + 1; + assert!(validate_manifest(&stale_schema).is_err()); + + let mut missing_timestamp = manifest(vec![manifest_file("game/index.html", 1)]); + missing_timestamp.synced_at_ms = 0; + assert!(validate_manifest(&missing_timestamp).is_err()); + } + + #[test] + fn project_snapshot_manifest_rejects_too_many_entries() { + let files = (0..=AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES) + .map(|index| manifest_file(&format!("game/file-{index}.txt"), 1)) + .collect::>(); + assert!(validate_manifest(&manifest(files)).is_err()); + } + + #[test] + fn project_snapshot_manifest_rejects_a_project_over_the_total_cap() { + // 单文件与条目数都在上限内,只有项目累计体积越界:这必须按 413 拒绝, + // 否则远端回收之后仍会长期占住超大配额。 + let files = (0..40) + .map(|index| { + manifest_file( + &format!("assets/blob-{index}.bin"), + AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES, + ) + }) + .collect::>(); + let error = validate_manifest(&manifest(files)).expect_err("超额项目必须被拒绝"); + assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn project_snapshot_user_quota_stops_after_the_hourly_file_limit() { + let user = "user-quota-fixture"; + for _ in 0..MAX_FILE_UPLOADS_PER_USER_PER_HOUR { + consume_user_upload_quota(user, ProjectSnapshotUploadKind::File) + .expect("配额内的上传必须放行"); + } + let error = consume_user_upload_quota(user, ProjectSnapshotUploadKind::File) + .expect_err("超出小时上限必须被拒绝"); + assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS); + // 清单与文件是两条独立配额,不受文件配额影响。 + consume_user_upload_quota(user, ProjectSnapshotUploadKind::Manifest) + .expect("清单配额独立计数"); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index b4030cd62..91c58792f 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -273,6 +273,8 @@ pub struct AppStateInner { test_external_background_removal_enqueue: Arc>>, oss_client: Option, + /// AGC 项目快照专用 OSS 客户端:bucket 与凭据可以独立于资源 bucket。 + project_snapshot_oss_client: Option, #[cfg_attr(test, allow(dead_code))] auth_store: InMemoryAuthStore, /// 当前进程工作集所基于的正式认证投影版本;跨节点写入使用它做 CAS。 @@ -339,6 +341,10 @@ impl fmt::Debug for AppStateInner { ) .field("admin_runtime_enabled", &self.admin_runtime.is_some()) .field("oss_client_enabled", &self.oss_client.is_some()) + .field( + "project_snapshot_oss_client_enabled", + &self.project_snapshot_oss_client.is_some(), + ) .field("spacetime_client", &self.spacetime_client) .field("tracking_outbox_enabled", &self.tracking_outbox.is_some()) .field( @@ -546,6 +552,7 @@ impl AppState { config.refresh_session_ttl_days, )?; let oss_client = build_oss_client(&config)?; + let project_snapshot_oss_client = build_project_snapshot_oss_client(&config)?; let sms_provider = SmsAuthProvider::new(SmsAuthConfig::new( SmsAuthProviderKind::parse(&config.sms_auth_provider).ok_or_else(|| { SmsProviderError::InvalidConfig("短信 provider 配置非法".to_string()) @@ -663,6 +670,7 @@ impl AppState { #[cfg(test)] test_external_background_removal_enqueue: Arc::new(Mutex::new(None)), oss_client, + project_snapshot_oss_client, auth_store, auth_projection_version: AtomicI64::new(auth_projection_version), auth_projection_synced_revision: AtomicU64::new(initial_auth_store_revision), @@ -1321,6 +1329,10 @@ impl AppState { self.oss_client.as_ref() } + pub fn project_snapshot_oss_client(&self) -> Option<&OssClient> { + self.project_snapshot_oss_client.as_ref() + } + pub fn password_entry_service(&self) -> &PasswordEntryService { &self.password_entry_service } @@ -2309,6 +2321,65 @@ impl AdminRuntime { } } +/// AGC 项目快照专用 OSS 客户端。 +/// +/// 目标 bucket 独立于资源 bucket:专用凭据未配置时回退 `ALIYUN_OSS_*`,而 bucket 与 +/// endpoint 默认指向 AGC 发行 bucket。凭据缺失或只配置一半时返回 `None`;路由层把 +/// "未配置" 当作失败关闭,不写空对象也不推进客户端索引。 +fn build_project_snapshot_oss_client( + config: &AppConfig, +) -> Result, AppStateInitError> { + let bucket = config.project_snapshot_oss_bucket.trim(); + let endpoint = config.project_snapshot_oss_endpoint.trim(); + if bucket.is_empty() || endpoint.is_empty() { + warn!("AGC 项目快照 OSS bucket/endpoint 未配置,跳过项目快照 OSS 客户端初始化"); + return Ok(None); + } + let access_key_id = config + .project_snapshot_oss_access_key_id + .as_deref() + .map(str::trim) + .unwrap_or(""); + let access_key_secret = config + .project_snapshot_oss_access_key_secret + .as_deref() + .map(str::trim) + .unwrap_or(""); + if access_key_id.is_empty() && access_key_secret.is_empty() { + return Ok(None); + } + if access_key_id.is_empty() || access_key_secret.is_empty() { + warn!("AGC 项目快照 OSS AccessKey 配置不完整,跳过项目快照 OSS 客户端初始化"); + return Ok(None); + } + let oss_config = OssConfig::new( + bucket.to_string(), + endpoint.to_string(), + access_key_id.to_string(), + access_key_secret.to_string(), + config.oss_read_expire_seconds, + config.oss_post_expire_seconds, + config.oss_post_max_size_bytes, + config.oss_success_action_status, + )?; + let credential_source = if config + .project_snapshot_oss_access_key_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID" + } else { + "ALIYUN_OSS_ACCESS_KEY_ID" + }; + info!( + bucket = %bucket, + endpoint = %endpoint, + credential_source, + "AGC 项目快照 OSS 客户端已启用" + ); + Ok(Some(OssClient::new(oss_config))) +} + fn build_oss_client(config: &AppConfig) -> Result, AppStateInitError> { let oss_fields = [ ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), diff --git a/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs new file mode 100644 index 000000000..2ebc8f144 --- /dev/null +++ b/server-rs/crates/platform-oss/examples/agc_project_snapshot_live_smoke.rs @@ -0,0 +1,278 @@ +//! AGC 项目快照真实 OSS 冒烟:直接验证 `agc-dev` bucket 上的内部前缀读写与清理。 +//! +//! 默认从仓库根目录的 `.env`、`.env.local`、`.env.secrets.local` 读取 OSS 配置, +//! 非空 shell 环境变量优先;凭据优先使用 +//! `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID`,未配置时回退 `ALIYUN_OSS_ACCESS_KEY_ID`。 +//! +//! ```text +//! cargo run -p platform-oss --example agc_project_snapshot_live_smoke --manifest-path server-rs/Cargo.toml +//! ``` +//! +//! 冒烟只写入 `agc/project-snapshots/v1/` 下的固定探针对象,并在结束时删除; +//! 任何一步失败都会打印 `[FAIL]` 并以非 0 退出码结束,方便 CI 或人工判定。 + +use std::{ + collections::{HashMap, HashSet}, + env, fs, + path::Path, + time::Duration, +}; + +use platform_oss::{ + DEFAULT_POST_EXPIRE_SECONDS, DEFAULT_POST_MAX_SIZE_BYTES, DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_SUCCESS_ACTION_STATUS, OssClient, OssConfig, OssDeleteObjectRequest, OssError, + OssInternalPutObjectRequest, OssObjectAccess, agc_project_snapshot_file_object_key, + agc_project_snapshot_manifest_object_key, +}; + +const DEFAULT_BUCKET: &str = "agc-dev"; +const DEFAULT_ENDPOINT: &str = "oss-rg-china-mainland.aliyuncs.com"; +const SMOKE_USER_ID: &str = "smoke-user"; +const SMOKE_PROJECT_ID: &str = "smoke-project"; +const SMOKE_RELATIVE_PATH: &str = "smoke/README.txt"; +const SMOKE_BODY: &[u8] = b"agc project snapshot live smoke\n"; +const SMOKE_CHECKSUM_DIGEST: &str = "0123456789abcdef"; + +type SmokeResult = Result; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + match run().await { + Ok(()) => { + println!("[PASS] AGC 项目快照 OSS 冒烟全部通过"); + } + Err(error) => { + println!("[FAIL] {error}"); + std::process::exit(1); + } + } +} + +async fn run() -> SmokeResult<()> { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let local_env = load_local_env(&repo_root)?; + let bucket = non_empty_env(&local_env, "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET") + .unwrap_or_else(|| DEFAULT_BUCKET.to_string()); + let endpoint = non_empty_env(&local_env, "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT") + .unwrap_or_else(|| DEFAULT_ENDPOINT.to_string()); + let access_key_id = non_empty_env( + &local_env, + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID", + ) + .or_else(|| non_empty_env(&local_env, "ALIYUN_OSS_ACCESS_KEY_ID")) + .ok_or_else(|| { + "缺少 AGC 项目快照 OSS AccessKey ID(或 ALIYUN_OSS_ACCESS_KEY_ID)".to_string() + })?; + let access_key_secret = non_empty_env( + &local_env, + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET", + ) + .or_else(|| non_empty_env(&local_env, "ALIYUN_OSS_ACCESS_KEY_SECRET")) + .ok_or_else(|| { + "缺少 AGC 项目快照 OSS AccessKey Secret(或 ALIYUN_OSS_ACCESS_KEY_SECRET)".to_string() + })?; + let config = OssConfig::new( + bucket.clone(), + endpoint.clone(), + access_key_id, + access_key_secret, + DEFAULT_READ_EXPIRE_SECONDS, + DEFAULT_POST_EXPIRE_SECONDS, + DEFAULT_POST_MAX_SIZE_BYTES, + DEFAULT_SUCCESS_ACTION_STATUS, + ) + .map_err(|error| format!("OSS 配置无效({})", oss_error_label(&error)))?; + let client = OssClient::new(config); + println!("目标:bucket={bucket} endpoint={endpoint}"); + + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .no_proxy() + .build() + .map_err(|error| format!("创建 HTTP 客户端失败:{error}"))?; + + // 1. 内部前缀白名单:非内部键必须在本地被拒绝,不产生网络请求。 + match client + .put_internal_object( + &http, + OssInternalPutObjectRequest { + object_key: "generated-characters/smoke/master.png".to_string(), + content_type: Some("application/octet-stream".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body: SMOKE_BODY.to_vec(), + }, + ) + .await + { + Err(OssError::InvalidRequest(_)) => println!("[PASS] 非内部前缀被本地拒绝,未写入 OSS"), + Ok(_) => return Err("非内部前缀竟然写入成功,内部前缀白名单失效".to_string()), + Err(error) => { + return Err(format!( + "非内部前缀返回了非预期错误({})", + oss_error_label(&error) + )); + } + } + + // 3. 项目快照文件键:写入 → 读回。 + let file_key = agc_project_snapshot_file_object_key( + SMOKE_USER_ID, + SMOKE_PROJECT_ID, + SMOKE_BODY.len() as u64, + SMOKE_CHECKSUM_DIGEST, + SMOKE_RELATIVE_PATH, + ) + .map_err(|error| format!("构造项目快照文件键失败({})", oss_error_label(&error)))?; + let manifest_key = agc_project_snapshot_manifest_object_key(SMOKE_USER_ID, SMOKE_PROJECT_ID) + .map_err(|error| format!("构造项目快照清单键失败({})", oss_error_label(&error)))?; + + let result = write_and_verify(&client, &http, &bucket, &file_key, &manifest_key).await; + for key in [&file_key, &manifest_key] { + let _ = client + .delete_object( + &http, + OssDeleteObjectRequest { + object_key: key.clone(), + }, + ) + .await; + } + match client.head_internal_object(&http, &file_key).await { + Ok(None) => println!("[PASS] 探针对象已清理"), + Ok(Some(_)) => println!("[WARN] 探针对象仍存在,请手工清理 {file_key}"), + Err(error) => println!( + "[WARN] 清理后复核失败({}),请手工确认 {file_key}", + oss_error_label(&error) + ), + } + result +} + +async fn write_and_verify( + client: &OssClient, + http: &reqwest::Client, + bucket: &str, + file_key: &str, + manifest_key: &str, +) -> SmokeResult<()> { + client + .put_internal_object( + http, + OssInternalPutObjectRequest { + object_key: file_key.to_string(), + content_type: Some("application/octet-stream".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body: SMOKE_BODY.to_vec(), + }, + ) + .await + .map_err(|error| { + format!( + "写入项目快照文件失败({}):凭据可能没有 {bucket} 的 PutObject 权限", + oss_error_label(&error) + ) + })?; + println!("[PASS] 项目快照文件已写入 OSS:{file_key}"); + + let existing = client + .head_internal_object(http, file_key) + .await + .map_err(|error| format!("读回项目快照文件失败({})", oss_error_label(&error)))? + .ok_or_else(|| "写入成功但对象读回不存在".to_string())?; + if existing.content_length != SMOKE_BODY.len() as u64 { + return Err(format!( + "对象长度不一致:期望 {},实际 {}", + SMOKE_BODY.len(), + existing.content_length + )); + } + println!( + "[PASS] 对象读回一致,服务端跳过判据成立:contentLength={} etag={}", + existing.content_length, + existing.etag.as_deref().unwrap_or("-") + ); + + client + .put_internal_object( + http, + OssInternalPutObjectRequest { + object_key: manifest_key.to_string(), + content_type: Some("application/json".to_string()), + access: OssObjectAccess::Private, + metadata: Default::default(), + body: br#"{"schemaVersion":1,"projectId":"smoke-project"}"#.to_vec(), + }, + ) + .await + .map_err(|error| format!("写入项目快照清单失败({})", oss_error_label(&error)))?; + println!("[PASS] 项目快照清单已覆盖写入 OSS:{manifest_key}"); + Ok(()) +} + +fn oss_error_label(error: &OssError) -> String { + format!("{error}") +} + +fn load_local_env(repo_root: &Path) -> SmokeResult> { + let shell = env::vars().collect::>(); + let protected = shell + .iter() + .filter(|(_, value)| !value.trim().is_empty()) + .map(|(key, _)| key.clone()) + .collect::>(); + let mut merged = shell; + for file_name in [".env", ".env.local", ".env.secrets.local"] { + let path = repo_root.join(file_name); + if !path.is_file() { + continue; + } + let contents = + fs::read_to_string(&path).map_err(|_| format!("无法读取本地配置文件 {file_name}"))?; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, raw_value)) = line.split_once('=') else { + continue; + }; + if !valid_env_key(key) || protected.contains(key) { + continue; + } + merged.insert( + key.to_string(), + trim_env_quotes(raw_value.trim()).to_string(), + ); + } + } + Ok(merged) +} + +fn valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + chars + .next() + .is_some_and(|value| value == '_' || value.is_ascii_alphabetic()) + && chars.all(|value| value == '_' || value.is_ascii_alphanumeric()) +} + +fn trim_env_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn non_empty_env(local_env: &HashMap, name: &str) -> Option { + local_env + .get(name) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 4a542a8c6..e74d511c5 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -114,6 +114,20 @@ pub struct OssDeleteObjectRequest { pub object_key: String, } +/// 服务端专用内部对象写入请求。 +/// +/// 与 `OssPutObjectRequest` 的区别是对象键由调用方按内部前缀完整给出,不再走 +/// `path_segments`/`file_name` 的低位规范化,因此可以保留项目内的原始大小写与 +/// 目录层级。键必须落在 `normalize_internal_object_key` 允许的内部前缀下。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssInternalPutObjectRequest { + pub object_key: String, + pub content_type: Option, + pub access: OssObjectAccess, + pub metadata: BTreeMap, + pub body: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssPutObjectRequest { pub prefix: LegacyAssetPrefix, @@ -796,11 +810,7 @@ impl OssClient { } let headers = response.headers(); - let content_length = headers - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); + let content_length = head_object_content_length(headers); let content_type = headers .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) @@ -933,6 +943,129 @@ impl OssClient { )) } + /// 按内部前缀写入服务端专用对象。内容为空、键越界都直接失败,不写空对象。 + pub async fn put_internal_object( + &self, + client: &reqwest::Client, + request: OssInternalPutObjectRequest, + ) -> Result { + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "服务端内部对象内容不能为空".to_string(), + )); + } + let object_key = normalize_internal_object_key(&request.object_key)?; + let content_type = normalize_optional_value(request.content_type); + let headers = build_put_object_headers(request.metadata)?; + let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) + .map_err(|error| { + request_error( + OssRequestOperation::Put, + &format!("构造 OSS 对象 URL 失败:{error}"), + ) + })?; + let content_length = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?; + let builder = signed_request_builder( + client, + &self.config, + Method::PUT, + Some(&object_key), + target_url, + content_type.as_deref(), + &headers, + )? + .header(reqwest::header::CONTENT_LENGTH, content_length) + .body(request.body); + let response = builder + .send() + .await + .map_err(|error| request_error_from_reqwest(OssRequestOperation::Put, error))?; + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Put, + response.status().as_u16(), + format!("OSS PutObject 失败,状态码:{}", response.status()), + )); + } + let headers = response.headers(); + let etag = headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()); + let last_modified = headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + Ok(OssPutObjectResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + legacy_public_path: format!("/{object_key}"), + object_key, + content_type, + content_length, + access: request.access, + etag, + last_modified, + }) + } + + /// 探测内部对象是否存在。`Ok(None)` 表示确定不存在,其余失败都按上游错误返回, + /// 调用方不能把不确定当成"不存在"。 + pub async fn head_internal_object( + &self, + client: &reqwest::Client, + object_key: &str, + ) -> Result, OssError> { + let object_key = normalize_internal_object_key(object_key)?; + let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) + .map_err(|error| { + request_error( + OssRequestOperation::Head, + &format!("构造 OSS 对象 URL 失败:{error}"), + ) + })?; + let response = send_signed_request( + client, + &self.config, + Method::HEAD, + Some(&object_key), + target_url, + OssRequestOperation::Head, + ) + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Head, + response.status().as_u16(), + format!("OSS HEAD Object 失败,状态码:{}", response.status()), + )); + } + let headers = response.headers(); + Ok(Some(OssHeadObjectResponse { + bucket: self.config.bucket.clone(), + object_key, + content_length: head_object_content_length(headers), + content_type: headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()), + etag: headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()), + last_modified: headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()), + })) + } + // AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。 pub async fn put_object( &self, @@ -1706,6 +1839,16 @@ fn build_policy_json( }) } +/// HEAD 响应没有正文,`reqwest::Response::content_length()` 对 HEAD 恒为 0; +/// 对象大小只能读响应头,两个 HEAD 入口共用这一处解析。 +fn head_object_content_length(headers: &reqwest::header::HeaderMap) -> u64 { + headers + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) +} + fn build_object_url( bucket: &str, endpoint: &str, @@ -1778,10 +1921,105 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result Result { + let user = validate_internal_key_segment(user_id, "用户标识")?; + let project = validate_internal_key_segment(project_id, "项目标识")?; + let digest = validate_internal_checksum_digest(checksum_digest)?; + let relative_path = validate_internal_relative_path(relative_path)?; + Ok(format!( + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/files/{size_bytes}-{digest}/{relative_path}" + )) +} + +/// 项目快照清单对象键:`agc/project-snapshots/v1/{user}/{project}/manifest.json`。 +pub fn agc_project_snapshot_manifest_object_key( + user_id: &str, + project_id: &str, +) -> Result { + let user = validate_internal_key_segment(user_id, "用户标识")?; + let project = validate_internal_key_segment(project_id, "项目标识")?; + Ok(format!( + "{AGC_PROJECT_SNAPSHOT_INTERNAL_PREFIX}{user}/{project}/manifest.json" + )) +} + +fn validate_internal_key_segment(raw: &str, label: &str) -> Result { + let trimmed = raw.trim(); + let allowed = !trimmed.is_empty() + && trimmed.len() <= 128 + && trimmed != "." + && trimmed != ".." + && trimmed.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }); + if !allowed { + return Err(OssError::InvalidRequest(format!( + "{label}不能作为 OSS 对象键片段" + ))); + } + Ok(trimmed.to_string()) +} + +fn validate_internal_checksum_digest(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(OssError::InvalidRequest( + "对象摘要必须是 1 到 64 位十六进制".to_string(), + )); + } + Ok(trimmed.to_ascii_lowercase()) +} + +fn validate_internal_relative_path(raw: &str) -> Result { + if raw.is_empty() || raw.len() > 1024 || raw.starts_with('/') || raw.contains('\\') { + return Err(OssError::InvalidRequest( + "对象相对路径必须是 1 到 1024 字节的正斜杠相对路径".to_string(), + )); + } + for part in raw.split('/') { + if part.is_empty() || part == "." || part == ".." || part.chars().any(char::is_control) { + return Err(OssError::InvalidRequest( + "对象相对路径包含非法片段".to_string(), + )); + } + } + Ok(raw.to_string()) +} + fn normalize_internal_object_key(raw: &str) -> Result { let normalized = raw.trim().trim_start_matches('/').trim().to_string(); validate_object_key_segments(&normalized)?; - if normalized.starts_with("agc/error-reports/v1/") { + if AGC_INTERNAL_OBJECT_PREFIXES + .iter() + .any(|prefix| normalized.starts_with(prefix)) + { Ok(normalized) } else { Err(OssError::InvalidRequest( @@ -3243,6 +3481,100 @@ mod tests { LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"), None ); + assert_eq!( + LegacyAssetPrefix::from_object_key( + "agc/project-snapshots/v1/user-1/project-1/manifest.json" + ), + None, + "AGC 内部前缀不能经由通用对象键解析变成客户端可写前缀" + ); + } + + #[test] + fn head_object_content_length_reads_the_response_header_not_the_head_body() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_LENGTH, + "32".parse().expect("content length header value"), + ); + assert_eq!( + head_object_content_length(&headers), + 32, + "HEAD 的对象大小必须来自响应头;reqwest 对 HEAD 的 body 长度恒为 0" + ); + assert_eq!( + head_object_content_length(&reqwest::header::HeaderMap::new()), + 0 + ); + } + + #[test] + fn agc_project_snapshot_object_keys_preserve_case_and_reject_traversal() { + let file_key = agc_project_snapshot_file_object_key( + "user-1", + "gameagent-1a2b3c4d", + 1234, + "0123456789ABCDEF", + "Game/Scenes/Main.HTML", + ) + .expect("file key"); + assert_eq!( + file_key, + "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/files/1234-0123456789abcdef/Game/Scenes/Main.HTML" + ); + assert_eq!( + agc_project_snapshot_manifest_object_key("user-1", "gameagent-1a2b3c4d") + .expect("manifest key"), + "agc/project-snapshots/v1/user-1/gameagent-1a2b3c4d/manifest.json" + ); + + for (user, project, path) in [ + ("../escape", "project-1", "game/index.html"), + ("user-1", "../escape", "game/index.html"), + ("user-1", "project-1", "../outside.txt"), + ("user-1", "project-1", "game/../../outside.txt"), + ("user-1", "project-1", "game\\index.html"), + ] { + assert!( + agc_project_snapshot_file_object_key(user, project, 1, "abcdef", path).is_err(), + "越界键片段必须被拒绝:{user} {project} {path}" + ); + } + assert!( + agc_project_snapshot_file_object_key("user-1", "project-1", 1, "not-hex", "game/a.txt") + .is_err(), + "摘要必须是十六进制" + ); + } + + #[test] + fn internal_object_prefixes_cover_agc_snapshots_but_reject_everything_else() { + let file_key = agc_project_snapshot_file_object_key( + "user-1", + "project-1", + 7, + "abcdef", + "game/index.html", + ) + .expect("file key"); + assert_eq!( + normalize_internal_object_key(&file_key).expect("snapshot key is internal"), + file_key + ); + assert_eq!( + normalize_internal_object_key("agc/error-reports/v1/batch.zip") + .expect("error report key stays internal"), + "agc/error-reports/v1/batch.zip" + ); + for key in [ + "generated-characters/hero/master.png", + "agc/other-purpose/v1/file.json", + ] { + assert!( + normalize_internal_object_key(key).is_err(), + "非内部前缀不能走服务端内部写入:{key}" + ); + } } #[test] diff --git a/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs b/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs new file mode 100644 index 000000000..7bc6c7dde --- /dev/null +++ b/server-rs/crates/shared-contracts/src/agc_project_snapshots.rs @@ -0,0 +1,139 @@ +//! AGC 项目定时快照上传的客户端 ↔ api-server 契约。 +//! +//! 客户端只负责把项目内发生变化的内容和当前清单交给 api-server;对象键、bucket +//! 与存储凭据全部留在服务端。因此这里只描述可达字段,不描述 OSS 细节。 + +use serde::{Deserialize, Serialize}; + +/// 清单格式版本。客户端与 api-server 必须一致,服务端拒绝其它版本。 +pub const AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// 单个文件的硬上限,与客户端扫描口径和 api-server 请求体上限保持一致。 +pub const AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; + +/// 单次清单包含的文件数量上限,防止越界请求把服务端内存拖垮。 +pub const AGC_PROJECT_SNAPSHOT_MAX_MANIFEST_FILES: usize = 20_000; + +/// 单次同步允许上传的累计字节上限(客户端预算)。超出部分留到下一次同步, +/// 与"单项目总上限"是两个不同概念。 +pub const AGC_PROJECT_SNAPSHOT_MAX_SYNC_BYTES: u64 = 512 * 1024 * 1024; + +/// 单个项目快照的累计体积上限。远端对象按当前清单做回收,所以这个上限同时约束 +/// 了该项目在 OSS 上的常驻占用。 +pub const AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +/// 单文件上传查询参数。文件正文走请求体,元数据走查询串,避免 base64 膨胀。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgcProjectSnapshotFileUploadQuery { + pub project_id: String, + pub relative_path: String, + pub checksum: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgcProjectSnapshotFileUploadResponse { + pub project_id: String, + pub relative_path: String, + pub object_key: String, + /// 服务端按对象元数据判定内容已存在时为 true;此时不写入新对象。 + pub skipped: bool, + pub checksum: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgcProjectSnapshotManifestFile { + pub relative_path: String, + pub size_bytes: u64, + pub checksum: String, +} + +/// 一次成功同步后的远端清单。删除文件只在这里消失,本期不删除远端对象。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgcProjectSnapshotManifestRequest { + pub schema_version: u32, + pub project_id: String, + pub sync_revision: u64, + pub synced_at_ms: u64, + pub files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgcProjectSnapshotManifestResponse { + pub project_id: String, + pub sync_revision: u64, + pub object_key: String, + pub file_count: u32, + pub total_bytes: u64, +} + +/// 项目身份校验。同一个值同时用作 AppData 目录名与 OSS 键段,因此必须拒绝路径 +/// 分隔符、相对段、控制字符与前导点。 +pub fn validate_agc_project_snapshot_project_id(value: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.len() > 128 || trimmed != value { + return Err("项目 ID 必须是 1 到 128 字节且不含首尾空白".to_string()); + } + let mut characters = trimmed.chars(); + let first = characters.next().unwrap_or_default(); + if !first.is_ascii_alphanumeric() { + return Err("项目 ID 必须以字母或数字开头".to_string()); + } + if !trimmed + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) + { + return Err("项目 ID 只能包含字母、数字、连字符、下划线和点".to_string()); + } + Ok(()) +} + +/// 相对路径校验:正斜杠分隔、逐段非空、拒绝绝对路径、相对段、控制字符与 +/// Windows 保留字符,与客户端 `normalize_relative_path` 同口径。 +pub fn validate_agc_project_snapshot_relative_path(value: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 1024 { + return Err("项目快照路径必须是 1 到 1024 字节".to_string()); + } + if value.starts_with('/') || value.contains('\\') { + return Err("项目快照路径必须是相对路径并使用正斜杠".to_string()); + } + if value.chars().any(char::is_control) { + return Err("项目快照路径不能包含控制字符".to_string()); + } + for part in value.split('/') { + if part.is_empty() || part == "." || part == ".." { + return Err("项目快照路径不能包含空段或相对段".to_string()); + } + if part.ends_with('.') || part.ends_with(' ') { + return Err("项目快照路径组件不能以点或空格结尾".to_string()); + } + if part + .chars() + .any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*')) + { + return Err("项目快照路径不能包含 Windows 保留字符".to_string()); + } + } + Ok(()) +} + +/// 校验和校验:只接受与项目索引同口径的 `fnv1a64:<16 位十六进制>`。 +pub fn validate_agc_project_snapshot_checksum(value: &str) -> Result<(), String> { + let Some(digest) = value.strip_prefix("fnv1a64:") else { + return Err("项目快照校验和必须使用 fnv1a64 前缀".to_string()); + }; + if digest.len() != 16 + || !digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("项目快照校验和必须是 16 位十六进制摘要".to_string()); + } + Ok(()) +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index bc32ee4b9..c3cc5ecaa 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod agc_project_snapshots; pub mod ai; pub mod api; #[cfg(feature = "oss-contracts")] From 55af6014ba1a16a608a6d531e075504f0a1b1de4 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:47:45 +0800 Subject: [PATCH 27/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=93=BE=E5=9B=9E=E9=80=80=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E9=AB=98=E6=B0=B4=E4=BD=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布脚本版本来源改为渠道清单与旧协议迁移指针取较大值,避免渠道启用初期把版本链改小 - 旧迁移指针只服务 dev-win,其它渠道不参与比较,旧指针 404 后自动只剩渠道清单 - 补充三条定向用例:旧指针高水位、两者取高、非 Windows 渠道不读旧指针 - 主规范记录版本高水位规则与 2026-09-17 首次渠道发布 0.1.57 退回 0.1.48、后以 0.1.60 纠偏的事实 --- .../scripts/build-release.mjs | 44 +++++++++++--- .../scripts/build-release.test.mjs | 58 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 2bfce8d02..579220319 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -143,27 +143,57 @@ export function resolveManifestPlatformKeys(target = releaseTarget) { throw new Error(`不支持的发布目标:${target}`); } -async function readRemoteVersion(channel = resolveReleaseChannel()) { - const manifestUrl = updateManifestUrl(channel); +/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */ +function legacyBridgeManifestUrl() { + return `${ossBaseUrl()}/latest.json`; +} + +async function readManifestVersion(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { headers: { Accept: 'application/json' }, }); } catch (error) { - throw new Error(`读取 OSS 渠道清单失败:${error.message}`); + throw new Error(`读取 ${label} 失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { - throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`); + throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } let manifest; try { manifest = await response.json(); } catch (error) { - throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`); + throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS渠道清单 version'); + return parseVersion(manifest?.version, `${label} version`); +} + +/** + * 版本高水位:渠道清单与旧协议迁移指针取较大值。 + * + * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 —— + * 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务 + * Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。 + */ +export async function resolveRemoteHighWaterVersion( + channel = resolveReleaseChannel(), +) { + const channelVersion = await readManifestVersion( + updateManifestUrl(channel), + 'OSS 渠道清单', + ); + if (channel !== 'dev-win') return channelVersion; + const legacyVersion = await readManifestVersion( + legacyBridgeManifestUrl(), + 'OSS 迁移指针', + ); + if (channelVersion == null) return legacyVersion; + if (legacyVersion == null) return channelVersion; + return compareVersions(channelVersion, legacyVersion) >= 0 + ? channelVersion + : legacyVersion; } function replaceVersionLine(source, version, pattern, label) { @@ -174,7 +204,7 @@ function replaceVersionLine(source, version, pattern, label) { export async function prepareReleaseVersion() { const channel = resolveReleaseChannel(); const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await readRemoteVersion(channel); + const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 904bb079f..f085a10d0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -13,6 +13,7 @@ import { nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, + resolveRemoteHighWaterVersion, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; @@ -49,6 +50,22 @@ function withSignedArtifact(fileName, run) { } } +function jsonResponse(body, status = 200) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; +} + +function withStubbedFetch(handler, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => handler(String(url)); + return Promise.resolve(run()).finally(() => { + globalThis.fetch = originalFetch; + }); +} + test('selects an explicit release artifact when configured', () => { const artifactPath = fileURLToPath( new URL('../package.json', import.meta.url), @@ -173,6 +190,47 @@ test('next release version follows the higher local or channel version', () => { assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); +test('version high water keeps the legacy pointer during the migration window', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({}, 404) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 + assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); + }, + ); +}); + +test('version high water takes the higher of channel and legacy pointer', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({ version: '0.1.60' }) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + }, + ); +}); + +test('version high water ignores the windows migration pointer for other channels', async () => { + await withStubbedFetch( + (url) => { + assert.ok( + !url.endsWith('/agc/latest.json'), + 'non-windows channel must not read the windows migration pointer', + ); + return jsonResponse({ version: '0.1.12' }); + }, + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + }, + ); +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 61a738e77..314b98419 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -82,6 +82,7 @@ - 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 +- 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。 - 迁移(旧协议 → 渠道清单): - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 From 7f80012d7f12cca8348225c827ddc5479b35495e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:21:45 +0800 Subject: [PATCH 28/68] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E9=87=8D=E5=BC=80=E7=8A=B6=E6=80=81=E9=87=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E7=9A=84=E5=8A=A8=E4=BD=9C=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成浮层的草稿、错误与占位归属统一按 draftId 判等,动作 ID 已无读取方,删掉避免留下死状态 --- .../src/view/project-development/index.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 90f4bad70..3a8b75685 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1792,7 +1792,6 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationPanelReopen, setResourceAssetGenerationPanelReopen, ] = useState<{ - actionId: string; /** 重开时要挂回的那张占位:草稿、浮层与占位三者的归属键就是它。 */ draftId: string; draft: ResourceCanvasAssetGenerationPanelDraft; @@ -7479,7 +7478,6 @@ export default function ProjectDevelopmentView({ settlement.error ?? '生成素材失败', ); setResourceAssetGenerationPanelReopen({ - actionId: submission.action.id, draftId: submission.draftId, draft: submission.draft, error: settlement.error ?? '生成素材失败', @@ -8147,7 +8145,6 @@ export default function ProjectDevelopmentView({ } if (retryTask) { setResourceAssetGenerationPanelReopen({ - actionId: placeholder.actionId, draftId: placeholder.draftId, draft: { prompt: retryTask.prompt, From b08ab6ee66c2581c1a751367dfe306ac1105c6cc Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:40:29 +0800 Subject: [PATCH 29/68] =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7=E3=80=81=E8=90=BD=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=8F=90=E4=BA=A4=E8=BA=AB=E4=BB=BD=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开生成浮层时把占位与浮层整块带进安全区(避开顶栏底栏),只做最小视口平移不改缩放 落点意图改为按草稿 ID 的 Map,避免并发完成互相覆盖;坐标在落点时刻取占位最新位置 落点 section 改用资源正式归类后的 category,修复图片落待归类、规范落文档时静默跳过 音频与背景音乐提交绑定占位与 operationId:在途时拒绝再次提交,失败保留输入并可同 operation 重试 宿主记录收起浮层时的草稿与音频提交身份,重开占位接着编辑而不是回到空表单 生成任务新增入口栏目 targetCategory 走原生可选入参,前端不据此伪分类 补可见性平移的定向测试 --- ...ResourceCanvasAssetGenerationPanelView.tsx | 24 +- .../ResourceCanvasGenerationPanelView.tsx | 51 +- .../resourceCanvasAssetGenerationQueue.ts | 4 + .../resourceCanvasAssetGenerationTaskModel.ts | 12 + ...resourceCanvasGenerationVisibilityModel.ts | 90 ++++ .../src/view/project-development/index.tsx | 449 ++++++++++++++---- ...resourceCanvasGenerationVisibility.test.ts | 88 ++++ 7 files changed, 606 insertions(+), 112 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index ede80749c..0b849b9a0 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -94,7 +94,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { * 面板自己不持有任何在途状态。 */ onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; - onClose: () => void; + /** + * 收起浮层。 + * + * 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。 + * 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。 + */ + onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void; }; /** @@ -189,6 +195,15 @@ export function ResourceCanvasAssetGenerationPanelView({ setPrompt(next.text); setReferences(next.references); }; + /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ + const closeWithDraft = () => + onClose({ + prompt, + assetName, + aspectRatio, + imageSize, + references, + }); function submit(event: FormEvent) { event.preventDefault(); @@ -215,6 +230,7 @@ export function ResourceCanvasAssetGenerationPanelView({ imageSize, references, }); + // 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。 onClose(); } @@ -227,7 +243,7 @@ export function ResourceCanvasAssetGenerationPanelView({ @@ -341,7 +357,7 @@ export function ResourceCanvasAssetGenerationPanelView({ 取消 @@ -373,7 +389,7 @@ export function ResourceCanvasAssetGenerationPanelView({ {panelBody} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index f94415b08..a4db6dbf8 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -45,8 +45,36 @@ export type ResourceCanvasGenerationPanelViewProps = { */ variant?: 'modal' | 'floating'; style?: CSSProperties | null; + /** + * 初始草稿(音频 / 背景音乐入口用)。 + * + * 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单, + * 用户不必重打一遍提示词。 + */ + initialDraft?: { + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + } | null; + /** + * 已绑定的提交身份。 + * + * 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是 + * 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。 + */ + request?: ResourceEditRequestIdentity | null; onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; - onClose: () => void; + /** + * 收起浮层。 + * + * 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。 + * 提交成功后的关闭不带草稿(这次输入已经被任务接走)。 + */ + onClose: (draft?: { + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + }) => void; }; const RESOURCE_GENERATION_ALL_KIND_ITEMS = @@ -77,6 +105,8 @@ export function ResourceCanvasGenerationPanelView({ initialKind, variant = 'modal', style, + initialDraft, + request: boundRequest, onSubmit, onClose, }: ResourceCanvasGenerationPanelViewProps) { @@ -94,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({ const option = resourceCanvasGenerationOption(kind); const panelTitle = allowedOptions.length === 1 ? option.generationLabel : '生成素材'; - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(option.assetName); + const [prompt, setPrompt] = useState(initialDraft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + initialDraft?.assetName ?? option.assetName, + ); const [attempted, setAttempted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸 // (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定 // 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。 - const requestRef = useRef(null); + const requestRef = useRef( + boundRequest ?? null, + ); const inputLocked = attempted || submitting; + /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ + const closeWithDraft = () => + onClose({ kind, prompt, assetName: assetName.trim() || option.assetName }); async function submit(event: FormEvent) { event.preventDefault(); @@ -145,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({ @@ -206,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({ {submitting ? '后台运行并关闭' : '取消'} @@ -250,7 +287,7 @@ export function ResourceCanvasGenerationPanelView({ {panelBody} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 2b0ac9634..90a31223b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -154,6 +154,10 @@ export function createResourceCanvasAssetGenerationQueue( assetName: task.assetName, referenceAssetIds: task.referenceAssetIds, outputPath: task.outputPath, + // 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。 + ...(task.targetCategory + ? { targetCategory: task.targetCategory } + : {}), })) as LocalProjectAssetGenerationTaskRecord; let started: LocalProjectAssetGenerationTaskRecord; try { diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index a6c4765f0..577b2665a 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -3,6 +3,7 @@ import { type ResourceCanvasAssetToolAction, resourceCanvasBottomToolActions, } from './resourceCanvasBottomToolbarModel'; +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; /** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */ export type ResourceCanvasAssetGenerationTaskStatus = @@ -56,6 +57,14 @@ export type ResourceCanvasAssetGenerationTask = { * 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。 */ referenceAssetIds: string[]; + /** + * 这次的**入口栏目**:用户点工具时正在看的那个栏目。 + * + * 原生侧按它把新素材直接登记进该栏目(`targetCategory`,可选入参);原生还没支持时它只是 + * 一条提示,落点仍按正式归类后的 section 走(见宿主落点 effect)。前端**不做**伪分类: + * 归类真相只在 manifest 与原生命令里。 + */ + targetCategory: ProjectResourceCanvasCategory | null; outputPath: string | null; projectId: string; /** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */ @@ -165,6 +174,7 @@ export function createResourceCanvasAssetGenerationTask(input: { aspectRatio: string; imageSize: string; referenceAssetIds?: readonly string[]; + targetCategory?: ProjectResourceCanvasCategory | null; outputPath: string | null; projectId: string; nowMillis: number; @@ -188,6 +198,7 @@ export function createResourceCanvasAssetGenerationTask(input: { .filter((assetId) => assetId.length > 0), ), ], + targetCategory: input.targetCategory ?? null, outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -220,6 +231,7 @@ export function restoreResourceCanvasAssetGenerationTask( aspectRatio: '', imageSize: '', referenceAssetIds: [], + targetCategory: null, outputPath: null, projectId: record.projectId, dispatched: true, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts new file mode 100644 index 000000000..f4c01e544 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts @@ -0,0 +1,90 @@ +import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types'; +import { normalizeResourceBookViewport } from '../../view/project-development/resourceBookViewport'; + +/** + * 生成浮层/占位的可见性:把「占位卡 + 它下沿的浮层」整块带进画布安全区。 + * + * 只做**最小平移**、不改缩放:用户的缩放预期不能被一次工具点击改掉。与既有 + * `ensureResourceBookContentVisible` / `centerResourceCanvasOnResource` 同一条口径, + * 区别只在于这里要求内容**完整**落在安全区里、并且知道顶栏与底栏要留出来多少。 + * + * 安全区 = 画布减去顶栏(栏目标题栏)与底栏(底部工具栏)后的区域:占位本来就可能被排到 + * 内容下方(真实浏览器 1280x720 上曾出现占位 y≈550、整块浮层落在可视区之外,用户只看到底栏)。 + */ +export type ResourceCanvasGenerationSafeInsets = { + top: number; + bottom: number; + left?: number; + right?: number; +}; + +export function revealResourceCanvasGenerationContent({ + viewport, + content, + canvasSize, + insets, + padding = 12, +}: { + viewport: CanvasViewport; + /** 需要完整可见的内容矩形(画布坐标):占位卡 + 浮层高度 + 两者之间的间隙。 */ + content: { x: number; y: number; width: number; height: number }; + canvasSize: { width: number; height: number }; + insets: ResourceCanvasGenerationSafeInsets; + /** 安全区左右默认留白(顶/底由 insets 覆盖)。 */ + padding?: number; +}): CanvasViewport { + const current = normalizeResourceBookViewport(viewport); + const finite = + Number.isFinite(content.x) && + Number.isFinite(content.y) && + Number.isFinite(content.width) && + Number.isFinite(content.height) && + Number.isFinite(canvasSize.width) && + Number.isFinite(canvasSize.height) && + canvasSize.width > 0 && + canvasSize.height > 0; + if (!finite || content.width <= 0 || content.height <= 0) { + return current; + } + const safeLeft = Math.max(0, insets.left ?? padding); + const safeTop = Math.max(0, insets.top); + const safeRight = Math.max( + safeLeft + 1, + canvasSize.width - Math.max(0, insets.right ?? padding), + ); + const safeBottom = Math.max( + safeTop + 1, + canvasSize.height - Math.max(0, insets.bottom), + ); + const screenLeft = current.x + content.x * current.scale; + const screenTop = current.y + content.y * current.scale; + const screenWidth = Math.max(1, content.width * current.scale); + const screenHeight = Math.max(1, content.height * current.scale); + const screenRight = screenLeft + screenWidth; + const screenBottom = screenTop + screenHeight; + + // 水平:先按左边界对齐,右边越界再整体左推;内容比安全区还宽时以左边界为准。 + let dx = 0; + if (screenRight > safeRight) { + dx = safeRight - screenRight; + } + if (screenLeft + dx < safeLeft) { + dx = safeLeft - screenLeft; + } + // 垂直:同理。占位被排到内容下方时要往上带,浮层顶到顶栏时要往下带。 + let dy = 0; + if (screenBottom > safeBottom) { + dy = safeBottom - screenBottom; + } + if (screenTop + dy < safeTop) { + dy = safeTop - screenTop; + } + if (dx === 0 && dy === 0) { + return current; + } + return { + scale: current.scale, + x: current.x + dx, + y: current.y + dy, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 3a8b75685..607d28766 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -131,6 +131,7 @@ import { type ResourceCanvasGenerationPlaceholder, } from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; +import { revealResourceCanvasGenerationContent } from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -627,6 +628,47 @@ function resourceCanvasElementSize(element: HTMLElement | null) { }; } +/** + * 顶栏与底栏占掉的安全区(相对画布顶边/底边)。 + * + * 两者都盖在画布上方,占位与生成浮层必须落在它们之间:真实浏览器 1280x720 上出现过占位排到 + * y≈550、整块浮层落在可视区之外,用户只看到底栏。量不到元素(首帧 / 非栏目页)时回退到常量, + * 宁可多留一点边距,也不要让面板贴到栏上。 + */ +const RESOURCE_GENERATION_SAFE_INSET_FALLBACK = { + top: 56, + bottom: 84, +}; +/** 面板高度量不到时的兜底(与浮层的 `max-height` 同量级),宁多留不贴栏。 */ +const RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK = 320; +/** 占位卡与它下沿浮层之间的间隙:与浮层锚点用同一个数。 */ +const RESOURCE_CANVAS_GENERATION_PANEL_GAP = 12; +function resourceGenerationOverlaySafeInsets(element: HTMLElement | null) { + const viewportElement = resourceCanvasViewportElement(element); + if (!viewportElement) { + return RESOURCE_GENERATION_SAFE_INSET_FALLBACK; + } + const canvasRect = viewportElement.getBoundingClientRect(); + const titlebarRect = viewportElement + .querySelector( + '.game-resource-book-scene-titlebar.is-active', + ) + ?.getBoundingClientRect(); + const toolbarRect = viewportElement + .querySelector('.game-resource-bottom-toolbar') + ?.getBoundingClientRect(); + return { + top: + titlebarRect && titlebarRect.height > 0 + ? Math.max(0, titlebarRect.bottom - canvasRect.top) + 10 + : RESOURCE_GENERATION_SAFE_INSET_FALLBACK.top, + bottom: + toolbarRect && toolbarRect.height > 0 + ? Math.max(0, canvasRect.bottom - toolbarRect.top) + 10 + : RESOURCE_GENERATION_SAFE_INSET_FALLBACK.bottom, + }; +} + function resourceCanvasViewportsEqual( left: CanvasViewport, right: CanvasViewport, @@ -1861,19 +1903,36 @@ export default function ProjectDevelopmentView({ viewportScale: () => resourceCanvasSceneViewportRef.current.scale ?? 1, }); /** - * 成功结果的落点意图:占位的最新位置。 + * 成功结果的落点意图:按草稿 ID 各自一条。 * * 生成完成时新资源还没进布局(reconcile 补位发生在下一次渲染),此刻直接 `commitPosition` 会在 * positions 里找不到它、被静默忽略。所以先记下意图,等这张卡真的进了布局再提交落点。 + * + * 用 Map 而不是单槽:多条任务可以在同一时刻收尾,单槽会互相覆盖、只落一条。 + * 也不在这里冻结坐标——真正落点时从占位当前状态取最新位置(用户可能刚把它拖走)。 */ - const resourceGenerationLandingRef = useRef<{ - projectId: string; - draftId: string; - resourceId: string; - category: ProjectResourceCanvasCategory; - x: number; - y: number; - } | null>(null); + const resourceGenerationLandingRef = useRef( + new Map(), + ); + /** + * 音频 / 背景音乐那条链路(`derive_local_project_resource`)的提交身份,按草稿 ID 记。 + * + * 它没有图片队列那本任务账本,提交身份就是面板铸造的 `operationId` + 幂等键:收起来再点开 + * 占位时必须复用同一对,否则重试会变成一次**新的**付费生成。 + */ + const resourceGenerationAudioRequestRef = useRef( + new Map(), + ); + /** 用户主动收起浮层时留下的可再编辑草稿(按草稿 ID 记,提交成功后清掉)。 */ + const resourceGenerationDraftRef = useRef( + new Map< + string, + { kind: ResourceCanvasGenerationKind; prompt: string; assetName: string } + >(), + ); + const resourceAssetGenerationDraftRef = useRef( + new Map(), + ); /** * 「定位到素材」的聚焦请求序号。 * @@ -2829,40 +2888,63 @@ export default function ProjectDevelopmentView({ /** * 结果落点:把成功产物落到它那张占位的**最新位置**。 * - * 等这张卡真的进了布局再提交坐标(生成完成时 reconcile 还没补位,提前提交会被静默忽略); - * 提交后撤掉占位——结果已经接管了它的位置。切项目时落点作废,不把旧项目的坐标写进新项目。 + * 两条判据缺一不可: + * 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作; + * 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源 + * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同 + * (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。 + * + * 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。 */ const removeResourceGenerationPlaceholder = resourceGenerationPlaceholders.remove; useEffect(() => { - const landing = resourceGenerationLandingRef.current; - if (!landing) { + const landings = resourceGenerationLandingRef.current; + if (landings.size === 0) { return; } - if ( - landing.projectId !== manifest.projectId || - landing.projectId !== resourceLayout.projectId - ) { - resourceGenerationLandingRef.current = null; - return; + for (const landing of [...landings.values()]) { + if ( + landing.projectId !== manifest.projectId || + landing.projectId !== resourceLayout.projectId + ) { + landings.delete(landing.draftId); + continue; + } + const placeholder = resourceGenerationPlaceholders.placeholderByDraftId( + landing.draftId, + ); + if (!placeholder) { + landings.delete(landing.draftId); + continue; + } + if (!resourcePositionById.has(landing.resourceId)) { + continue; + } + const projected = resources.find( + (resource) => resource.id === landing.resourceId, + ); + if (!projected) { + continue; + } + landings.delete(landing.draftId); + activeResourceLayout.commitPosition( + landing.resourceId, + projected.category, + // 最新位置:用户可能在生成期间把占位拖到了别处。 + placeholder.x, + placeholder.y, + ); + removeResourceGenerationPlaceholder(landing.draftId); } - if (!resourcePositionById.has(landing.resourceId)) { - return; - } - resourceGenerationLandingRef.current = null; - activeResourceLayout.commitPosition( - landing.resourceId, - landing.category, - landing.x, - landing.y, - ); - removeResourceGenerationPlaceholder(landing.draftId); }, [ activeResourceLayout, manifest.projectId, removeResourceGenerationPlaceholder, + resourceGenerationPlaceholders, resourceLayout.projectId, resourcePositionById, + resources, ]); const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( @@ -7321,72 +7403,129 @@ export default function ProjectDevelopmentView({ if (!invoke) { throw new Error('生成资源需要在客户端内执行'); } + const placeholder = draftId + ? resourceGenerationPlaceholders.placeholderByDraftId(draftId) + : null; + /* + 同一张占位已经在后台跑:**不允许**用新的 operationId 再发一次——那是一次重复付费生成。 + 同一 operationId 的重试(面板失败后点原请求重试)走原生账本幂等,不在禁止之列。 + */ + if ( + placeholder?.status === 'submitted' && + placeholder.taskId !== input.operationId + ) { + throw new Error('这次生成已在后台继续,请等它结束或失败后再重试'); + } + if (placeholder) { + // 提交身份与被绑定的 operationId 一起记在占位上:关闭面板再点开也用同一 operation 重试。 + resourceGenerationAudioRequestRef.current.set(placeholder.draftId, { + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + // 提示词是身份的一部分:原生指纹含 prompt,脱开它就变成另一次请求。 + prompt: input.prompt, + }); + resourceGenerationPlaceholders.bindTask( + placeholder.draftId, + input.operationId, + ); + } const option = resourceCanvasGenerationOption(input.kind); const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - const result = await withPlatformSessionRefresh(() => - invoke( - 'derive_local_project_resource', - { - input: { - projectPath, - expectedProjectId: actionProject.projectId, - expectedProjectRevision: status.revision, - operationId: input.operationId, - idempotencyKey: input.idempotencyKey, - editKind: option.editKind, - generationMode: 'create', - sourceResourceId: resourceCanvasGenerationSourceId( - input.operationId, - ), - sourceAssetId: null, - sourcePath: null, - sourceMediaType: option.sourceMediaType, - sourceSubtype: null, - producerTaskId: null, - sourceVersionId: null, - prompt: input.prompt, - assetName: input.assetName, + try { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: actionProject.projectId, + expectedProjectRevision: status.revision, + operationId: input.operationId, + idempotencyKey: input.idempotencyKey, + editKind: option.editKind, + generationMode: 'create', + sourceResourceId: resourceCanvasGenerationSourceId( + input.operationId, + ), + sourceAssetId: null, + sourcePath: null, + sourceMediaType: option.sourceMediaType, + sourceSubtype: null, + producerTaskId: null, + sourceVersionId: null, + prompt: input.prompt, + assetName: input.assetName, + }, }, - }, - ), - ); - if ( - !result.asset || - result.manifest.projectId !== actionProject.projectId - ) { - throw new Error('生成资源结果与当前项目不一致'); + ), + ); + if ( + !result.asset || + result.manifest.projectId !== actionProject.projectId + ) { + throw new Error('生成资源结果与当前项目不一致'); + } + onManifestChange?.(projectPath, result.manifest, { + projectId: result.manifest.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: result.operationId, + }); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: result.operationId, + sessionId: result.operationId, + draftId: result.operationId, + commitId: result.operationId, + projectPath, + projectId: result.manifest.projectId, + focusGeneration: focusGenerationRef.current, + resourceId: `asset:${result.asset.id}`, + completed: false, + }; + setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); + if (placeholder) { + /* + 成功落点:与图片类生成同一条口径——按**正式归类后的 section**提交占位的**最新位置**, + 等新卡进了布局再落(见落点 effect)。这里只记意图,不冻结坐标。 + */ + resourceGenerationLandingRef.current.set(placeholder.draftId, { + projectId: placeholder.projectId, + draftId: placeholder.draftId, + resourceId: `asset:${result.asset.id}`, + }); + resourceGenerationAudioRequestRef.current.delete(placeholder.draftId); + } + setResourceGenerationDraft(null); + } catch (error) { + /* + 失败:占位留在画布上(输入与操作身份都还在,面板自己展示原因),状态收口为失败。 + 重试沿用同一 operationId,命中原生幂等账本——不会重复发起一次付费生成。 + */ + if (placeholder) { + resourceGenerationPlaceholders.failTask( + input.operationId, + error instanceof Error ? error.message : String(error), + ); + } + throw error; } - onManifestChange?.(projectPath, result.manifest, { - projectId: result.manifest.projectId, - revision: result.committedProjectRevision, - source: 'asset-command', - commitId: result.operationId, - }); - activeFocusFlowIdRef.current = flowId; - pendingResourceFocusRef.current = { - flowId, - saveAttemptId: result.operationId, - sessionId: result.operationId, - draftId: result.operationId, - commitId: result.operationId, - projectPath, - projectId: result.manifest.projectId, - focusGeneration: focusGenerationRef.current, - resourceId: `asset:${result.asset.id}`, - completed: false, - }; - setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - setResourceGenerationDraft(null); - }, - [manifest.projectId, onManifestChange, projectPath], + }, + [ + manifest.projectId, + onManifestChange, + projectPath, + resourceGenerationPlaceholders, + ], ); /** @@ -7515,14 +7654,11 @@ export default function ProjectDevelopmentView({ const landingPlaceholder = resourceGenerationPlaceholders.placeholderByTaskId(settlement.taskId); if (landingPlaceholder) { - resourceGenerationLandingRef.current = { + resourceGenerationLandingRef.current.set(landingPlaceholder.draftId, { projectId: landingPlaceholder.projectId, draftId: landingPlaceholder.draftId, resourceId: `asset:${assetId}`, - category: landingPlaceholder.category, - x: landingPlaceholder.x, - y: landingPlaceholder.y, - }; + }); } let fresh: Awaited< ReturnType @@ -7655,6 +7791,11 @@ export default function ProjectDevelopmentView({ referenceAssetIds: resourceCanvasAssetGenerationReferenceIds( input.references, ), + // 入口栏目随任务带上(原生 `targetCategory` 可选入参)。前端不拿它当分类真相: + // 落点仍按正式归类后的 section 走。 + targetCategory: + resourceGenerationPlaceholders.placeholderByDraftId(draftId) + ?.category ?? null, outputPath: resourceCanvasAssetGenerationOutputPath( action, context.hasIconSpecReference, @@ -7676,6 +7817,8 @@ export default function ProjectDevelopmentView({ dispatchedImmediately, }; setResourceAssetGenerationPanelReopen(null); + // 这次输入已经被任务接走:收起草稿的副本不再需要。 + resourceAssetGenerationDraftRef.current.delete(draftId); // 占位从「待提交」进入「生成中」:任务已经交给后台,关闭浮层不影响它。 resourceGenerationPlaceholders.bindTask(draftId, task.taskId); setResourceAssetGenerationTasksPanelOpen(true); @@ -7712,6 +7855,10 @@ export default function ProjectDevelopmentView({ // 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。 setResourceGenerationDraft(null); setResourceAssetGenerationPanel(null); + // 收起时的草稿与音频提交身份同样是本项目内的记忆,换项目一并作废。 + resourceGenerationAudioRequestRef.current.clear(); + resourceGenerationDraftRef.current.clear(); + resourceAssetGenerationDraftRef.current.clear(); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -8070,6 +8217,73 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + /** + * 打开生成浮层时把「占位卡 + 浮层」整块带进可视区。 + * + * 工具点击的落点排在当前栏目内容下方,栏目内容一多就落到视口之外:面板虽然渲染出来了, + * 用户看到的只有底栏(真实浏览器 1280x720 复现过)。这里用最小的视口平移把它带回来,不改缩放 + * (与既有点位定位同一条口径),并留出顶栏 / 底栏的安全区。 + */ + useEffect(() => { + if (!resourceGenerationPanelDraftId) { + return; + } + const placeholder = resourceGenerationPlaceholders.placeholderByDraftId( + resourceGenerationPanelDraftId, + ); + if (!placeholder) { + return; + } + const category: ResourceBookTarget | null = resourceBookOpensAllResources + ? RESOURCE_BOOK_ALL_TARGET + : activePageCategory; + if (!category || resourceBookView !== 'child') { + return; + } + const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return; + } + const panelElement = resourceCanvasRef.current?.querySelector( + '[data-resource-canvas-generation-floating-panel]', + ); + // 面板高度量得到就用实测值:Lexical 输入区与规格行都会撑高它。 + const panelHeight = Math.max( + 0, + panelElement?.getBoundingClientRect().height ?? + RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK, + ); + const viewport = normalizeResourceBookViewport( + category === RESOURCE_BOOK_ALL_TARGET + ? resourceBookAllViewportRef.current + : resourceCanvasViewportRef.current, + ); + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: + placeholder.height + + RESOURCE_CANVAS_GENERATION_PANEL_GAP + + panelHeight, + }, + canvasSize, + insets: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current), + }); + if (resourceCanvasViewportsEqual(next, viewport)) { + return; + } + setResourceCanvasViewport(category, next); + }, [ + activePageCategory, + resourceBookOpensAllResources, + resourceBookView, + resourceGenerationPanelDraftId, + resourceGenerationPlaceholders, + setResourceCanvasViewport, + ]); /** * 收起生成浮层。 @@ -8169,7 +8383,15 @@ export default function ProjectDevelopmentView({ error: placeholder.error ?? '生成素材失败', }); } else { - setResourceAssetGenerationPanelReopen(null); + // 用户自己收起过浮层:接着编辑同一份草稿,而不是回到空表单。 + const closedDraft = resourceAssetGenerationDraftRef.current.get( + placeholder.draftId, + ); + setResourceAssetGenerationPanelReopen( + closedDraft + ? { draftId: placeholder.draftId, draft: closedDraft, error: '' } + : null, + ); } setResourceAssetGenerationPanel({ action: placeholder.panel.action, @@ -8927,17 +9149,35 @@ export default function ProjectDevelopmentView({ initialKind={resourceGenerationDraft.initialKind} variant="floating" style={resourceGenerationPanelStyle} + // 用户收起过浮层就接着编辑同一份草稿(不是空表单)。 + initialDraft={ + resourceGenerationDraftRef.current.get( + resourceGenerationDraft.draftId, + ) ?? null + } + // 已提交过的草稿复用同一对 operationId / 幂等键,重试不会变成新生成。 + request={ + resourceGenerationAudioRequestRef.current.get( + resourceGenerationDraft.draftId, + ) ?? null + } onSubmit={(input) => submitResourceCanvasGeneration( input, resourceGenerationDraft.draftId, ) } - onClose={() => + onClose={(draft) => { + if (draft) { + resourceGenerationDraftRef.current.set( + resourceGenerationDraft.draftId, + draft, + ); + } closeResourceGenerationFloatingPanel( resourceGenerationDraft.draftId, - ) - } + ); + }} /> ) : null} {resourceAssetGenerationPanel && @@ -8980,7 +9220,14 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationPanel.draftId, ) } - onClose={() => { + onClose={(closedDraft) => { + if (closedDraft) { + // 关闭 ≠ 丢弃输入:草稿按占位归属存下来,点开占位时接着编辑。 + resourceAssetGenerationDraftRef.current.set( + resourceAssetGenerationPanel.draftId, + closedDraft, + ); + } setResourceAssetGenerationPanelReopen((current) => current?.draftId === resourceAssetGenerationPanel.draftId diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts new file mode 100644 index 000000000..b8234ff1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; + +import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; + +const canvasSize = { width: 800, height: 600 }; +const insets = { top: 60, bottom: 90 }; + +describe('生成浮层与占位的可见性', () => { + test('内容已经在安全区内时坐标不变(宿主据此跳过写回)', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 80, width: 180, height: 200 }, + canvasSize, + insets, + }); + expect(next).toEqual(viewport); + }); + + test('内容落到视口下方时做最小上移,底边贴住底栏安全区', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + // 占位在 y=550、加上浮层后整块落到视口外——真实浏览器上复现过的那一档。 + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 550, width: 180, height: 320 }, + canvasSize, + insets, + }); + const safeBottom = canvasSize.height - insets.bottom; + expect(viewport.y + (550 + 320)).toBeGreaterThan(safeBottom); + expect(next.y + 550 + 320).toBe(safeBottom); + expect(next.x).toBe(0); + expect(next.scale).toBe(1); + }); + + test('内容顶出顶栏时下移,内容越出左右边时做最小横移', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: -300, y: -40, width: 180, height: 120 }, + canvasSize, + insets, + }); + expect(next.y).toBe(insets.top + 40); + expect(next.x).toBe(12 + 300); + }); + + test('缩放参与换算:缩小后的越界按同一口径平移,比例保持不变', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 0.5 }, + content: { x: 40, y: 900, width: 180, height: 320 }, + canvasSize, + insets, + }); + expect(next.scale).toBe(0.5); + const safeBottom = canvasSize.height - insets.bottom; + expect(next.y + (900 + 320) * 0.5).toBe(safeBottom); + }); + + test('内容比安全区还大时以顶边 / 左边对齐,不来回抖', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: 0, y: 0, width: 2000, height: 1200 }, + canvasSize, + insets, + }); + expect(next).toEqual({ scale: 1, x: 12, y: insets.top }); + }); + + test('尺寸非法或画布未就绪时原样返回', () => { + const viewport = { x: 5, y: 6, scale: 1 }; + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 0, height: 0 }, + canvasSize, + insets, + }), + ).toEqual(viewport); + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 10, height: 10 }, + canvasSize: { width: 0, height: 0 }, + insets, + }), + ).toEqual(viewport); + }); +}); From 6001b87215be0581d3876e166c21ec4d05ee7f99 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:52:06 +0800 Subject: [PATCH 30/68] =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=8C=89=E5=85=A5=E5=8F=A3=E6=A0=8F=E7=9B=AE=E8=90=BD=E7=9B=98?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI 生成命令新增可选 targetCategory:generate_local_project_asset 与 start_local_project_asset_generation 经 options 传入完成登记 PlatformArtAssetGenerationOptions 增加 target_category,Agent 与 Direct 路径保持 None,仍按 kind 派生默认分类 新建条目与同 kind 重登记都按显式目标分类写入 manifest 既有 category 字段,不新增字段、不改数据结构 目标分类只接受 GameCreationAppAssetCategory 的六个合法值,version 与 all 等栏目伪值在提交前失败关闭 target_category 刻意不进 standalone 指纹与 durable 请求快照,分类只影响本地 manifest 落点,避免在途账本换槽重复 POST,同任务幂等恢复由调用方继续用同一栏目提交 补用例:kind=image 带 targetCategory=character 落到 character 而不是 unclassified、非法栏目值不产生生成请求、登记入口的显式分类与不传时的原 kind 派生行为 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 1 + .../src-tauri/src/agent/direct_tool_bridge.rs | 1 + .../src-tauri/src/agent/generation.rs | 4 +- .../src/agent/generation/canvas_generation.rs | 46 ++++- .../src/agent/runtime_tools/media.rs | 3 + .../src-tauri/src/asset_generation_tasks.rs | 4 + .../src-tauri/src/assets.rs | 182 +++++++++++++++++- .../src-tauri/src/commands.rs | 80 +++++++- .../src-tauri/src/tests/project.rs | 99 ++++++++++ 9 files changed, 408 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 7a0b84b13..f18c6c724 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3396,6 +3396,7 @@ async fn generate_direct_taonier_art_asset_at( grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 19858c852..39f75b62e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2263,6 +2263,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) grid_x, grid_y, reference_asset_ids: Vec::new(), + target_category: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 1e1600dbe..4bad4708e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -67,8 +67,8 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, - normalize_platform_art_reference_asset_ids, platform_art_asset_art_spec, - platform_art_asset_output_extension_matches, + normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category, + platform_art_asset_art_spec, platform_art_asset_output_extension_matches, platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index b298c0829..36409b5e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -430,6 +430,18 @@ pub(crate) struct PlatformArtAssetGenerationOptions { /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 pub(crate) reference_asset_ids: Vec, + /// GUI 生成完成时要落盘的**正式功能分类**(前端 `targetCategory`,取值与 + /// [`update_local_project_resource_classification_at`] 同一套词汇)。 + /// + /// 只有 GUI 侧 `start_local_project_asset_generation` / `generate_local_project_asset` 会传值: + /// 入口栏目生成的是 `kind=image` / `icon-spec`,按 kind 派生只会落到 `unclassified` / + /// `document`,与入口栏目不一致,占位无法被原位接管。Agent / Direct 路径保持 `None`, + /// 继续按 kind 派生默认分类。 + /// + /// 它**刻意不进** standalone 动作指纹与 durable 请求快照:指纹只用来定位同一个计费输出槽, + /// 改材料会让升级时在途的账本换槽并重复 POST;分类只影响本地 manifest 落盘,不影响远端 + /// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。 + pub(crate) target_category: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -446,10 +458,35 @@ impl Default for PlatformArtAssetGenerationOptions { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, } } } +/// 收口 GUI 完成登记层的目标分类:只接受 [`GameCreationAppAssetCategory`] 的合法取值 +/// (`ui-interaction` / `character` / `scene` / `audio` / `document` / `unclassified`), +/// 归一成落盘字符串。`version` / `all` 等栏目侧伪值不在枚举里,一律拒绝。 +pub(crate) fn normalize_platform_art_target_category( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + let category = game_creation_app_asset_category_from_str(target_category) + .ok_or_else(|| format!("目标分类不是合法素材分类:{target_category}"))?; + // 落盘字符串直接取枚举自己的 kebab-case 序列化,避免再抄一份 vocabulary 出来漂移。 + let value = + serde_json::to_value(category).map_err(|error| format!("目标分类无法序列化:{error}"))?; + let value = value + .as_str() + .ok_or_else(|| "目标分类不是字符串枚举".to_string())?; + Ok(Some(value.to_string())) +} + /// 普通图片生成合并规范图与用户参考后的**总参考上限**,沿用图片生成 API 已有上限。 pub(crate) const PLATFORM_ART_MAX_REFERENCE_IMAGES: usize = 5; /// 有规范图前置时允许的用户参考上限:规范图本身占 1 张,总量仍不超过 @@ -7928,7 +7965,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( return Err(error); } } - let registered = match register_local_asset_entry( + let registered = match register_local_asset_entry_with_category( root, &local_path, &options.asset_kind, @@ -7946,6 +7983,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generation_kind: Some(generation_kind.clone()), reference_resource_ids: reference_resource_ids.clone(), }, + // GUI 完成登记层的目标栏目;Agent / Direct 路径为 `None`,仍按 kind 派生。 + options.target_category.as_deref(), ) { Ok(registered) => registered, Err(error) => { @@ -8658,6 +8697,7 @@ mod canvas_generation_tests { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -10706,6 +10746,7 @@ mod canvas_generation_tests { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11611,6 +11652,7 @@ mod canvas_generation_tests { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12076,6 +12118,7 @@ mod canvas_generation_tests { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -13004,6 +13047,7 @@ mod canvas_generation_tests { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 34c4b3e21..1928a439c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -578,6 +578,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio grid_x, grid_y, reference_asset_ids: Vec::new(), + target_category: None, }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -630,6 +631,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio grid_x, grid_y, reference_asset_ids: requested_options.reference_asset_ids, + // Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。 + target_category: requested_options.target_category, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 6807a6e28..f228420f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -393,6 +393,9 @@ pub(crate) async fn start_local_project_asset_generation( // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -404,6 +407,7 @@ pub(crate) async fn start_local_project_asset_generation( asset_name.as_deref(), output_path.as_deref(), reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 110e35161..2a053191c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,5 +1,6 @@ use super::*; use sha2::{Digest as _, Sha256}; +use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::future::Future; const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; @@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result generation_kind: None, reference_resource_ids: Vec::new(), }, + None, )?; changed |= asset_changed; } @@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { - register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source) - .map(|(result, _)| result) + register_local_asset_entry_with_change( + root, local_path, kind, media_type, id_prefix, source, None, + ) + .map(|(result, _)| result) +} + +/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。 +/// +/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`, +/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回 +/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过 +/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`], +/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走 +/// [`register_local_asset_entry`],行为不变。 +pub(crate) fn register_local_asset_entry_with_category( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, + target_category: Option<&str>, +) -> Result { + let target_category = normalize_asset_category_override(target_category)?; + register_local_asset_entry_with_change( + root, + local_path, + kind, + media_type, + id_prefix, + source, + target_category, + ) + .map(|(result, _)| result) +} + +/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。 +fn normalize_asset_category_override( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + game_creation_app_asset_category_from_str(target_category) + .map(Some) + .ok_or_else(|| format!("非法资源分类:{target_category}")) } fn register_local_asset_entry_with_change( @@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change( media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, + target_category: Option, ) -> Result<(UploadLocalAssetResult, bool), String> { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; @@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change( // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 let changed = existing.kind != kind || existing.media_type != media_type - || existing.source != source; + || existing.source != source + || target_category.is_some_and(|category| existing.category != category); if existing.kind != kind { existing.kind = kind.to_string(); existing.category = game_creation_app_asset_category_for_kind(kind); } + // 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目, + // 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。 + if let Some(category) = target_category { + existing.category = category; + } existing.media_type = media_type.to_string(); existing.source = source; Ok((existing.id.clone(), "asset.update", changed)) @@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change( local_path: normalized_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(kind), + category: target_category + .unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)), tags: Vec::new(), source, }); @@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; #[test] @@ -2176,6 +2235,121 @@ mod tests { assert!(!register_design_artifacts_at(root).expect("register idempotently")); } + /// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。 + /// + /// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生 + /// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。 + #[test] + fn explicit_target_category_overrides_the_kind_derived_category() { + fn canvas_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } + } + fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("registered asset is present") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记") + .expect("init project"); + fs::create_dir_all(root.join("assets")).expect("create assets dir"); + fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset"); + + let registered = register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("character"), + ) + .expect("register with a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::Character + ); + + // 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。 + register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("ui-interaction"), + ) + .expect("re-register with another target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 非法值失败关闭,且不动已落盘的分类。 + assert!(register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("version"), + ) + .is_err()); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。 + fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset"); + let plain = register_local_asset_entry( + root, + "assets/plain.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("register without a target category"); + assert_eq!( + category_of(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + // 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。 + register_local_asset_entry( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("re-register without a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 /// /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 832517ff6..c5ceba53a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4611,6 +4611,7 @@ pub(crate) fn prepare_local_project_asset_generation( asset_name: Option<&str>, output_path: Option<&str>, reference_asset_ids: &[String], + target_category: Option<&str>, ) -> Result { let project_path = project_path.trim(); if project_path.is_empty() { @@ -4622,6 +4623,9 @@ pub(crate) fn prepare_local_project_asset_generation( // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 let reference_asset_ids = normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?; + // GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类 + // 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。 + let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), prompt: local_project_asset_prompt(prompt)?, @@ -4656,6 +4660,7 @@ pub(crate) fn prepare_local_project_asset_generation( grid_x: None, grid_y: None, reference_asset_ids, + target_category, }, }) } @@ -4678,6 +4683,9 @@ pub(crate) async fn generate_local_project_asset( asset_name: Option, output_path: Option, reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类, + // 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。 + target_category: Option, ) -> Result { let request = prepare_local_project_asset_generation( &project_path, @@ -4688,6 +4696,7 @@ pub(crate) async fn generate_local_project_asset( asset_name.as_deref(), output_path.as_deref(), reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -4715,6 +4724,7 @@ mod local_project_asset_generation_tests { None, None, &[], + None, ) } @@ -4756,6 +4766,7 @@ mod local_project_asset_generation_tests { Some(" 主角图集 "), Some(" assets/hero.png "), &[], + None, ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -4791,7 +4802,8 @@ mod local_project_asset_generation_tests { None, None, None, - &[] + &[], + None, ) .expect_err("empty project path"), "项目路径不能为空" @@ -4804,6 +4816,60 @@ mod local_project_asset_generation_tests { prepare("game-art", "要求").expect_err("unverified kind"), "素材类型不受支持:game-art" ); + // 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。 + for rejected in ["version", "all", "bogus", "UI"] { + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(rejected), + ) + .expect_err("illegal target category"), + format!("目标分类不是合法素材分类:{rejected}") + ); + } + // 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。 + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(" ui-interaction "), + ) + .expect("legal target category") + .options + .target_category + .as_deref(), + Some("ui-interaction") + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect("omitted target category") + .options + .target_category, + None + ); assert_eq!( prepare( "spec", @@ -4821,7 +4887,8 @@ mod local_project_asset_generation_tests { None, None, None, - &[] + &[], + None, ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -4835,7 +4902,8 @@ mod local_project_asset_generation_tests { Some("4K"), None, None, - &[] + &[], + None, ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -4849,7 +4917,8 @@ mod local_project_asset_generation_tests { None, Some("坏\u{7}名字"), None, - &[] + &[], + None, ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -4863,7 +4932,8 @@ mod local_project_asset_generation_tests { None, None, Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), - &[] + &[], + None, ) .expect_err("oversized output path"), "输出路径超出安全边界" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index a5a201fbd..135f67701 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1159,6 +1159,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }, ) .await; @@ -3113,6 +3114,7 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("工具栏图片".to_string()), None, None, + None, ) .await .expect("toolbar asset generation"); @@ -3145,6 +3147,99 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn generate_local_project_asset_command_lands_the_requested_target_category() { + // 入口栏目与生成 kind 不是同一套词汇:kind=image 按 kind 派生只会落到 unclassified, + // 必须靠 targetCategory 才能落回入口栏目,否则生成完成后占位无法被原位接管。 + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-category-user", + "editor-toolbar-category-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-toolbar-category-key" } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "project-toolbar-category", "未命名游戏原型") + .expect("init project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation"); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + Some("1:1".to_string()), + Some("1K".to_string()), + Some("主角图".to_string()), + None, + None, + Some("character".to_string()), + ) + .await + .expect("target category generation"); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered asset with target category"); + // kind 仍是请求的生成 kind,栏目取显式目标分类,而不是 kind 派生的 unclassified。 + assert_eq!(entry["kind"], "image"); + assert_eq!(entry["category"], "character"); + + // 先排掉这一次生成自己的请求,再验证非法栏目值不会带来任何新的生成请求。 + while request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_ok() + {} + for rejected in ["version", "all", "bogus"] { + let error = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + None, + None, + None, + None, + None, + Some(rejected.to_string()), + ) + .await + .expect_err("illegal target category must fail closed"); + assert!( + error.contains("目标分类不是合法素材分类"), + "{rejected}: {error}" + ); + } + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { let root = unique_project_path(); @@ -3180,6 +3275,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s Some("视觉规范图".to_string()), None, None, + None, ) .await .expect("toolbar spec generation"); @@ -3247,6 +3343,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -3271,6 +3368,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("游戏首版图集".to_string()), None, None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -5960,6 +6058,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { grid_x: None, grid_y: None, reference_asset_ids: Vec::new(), + target_category: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", From 6017d46088c04199e99cf89f347b12d67591475e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:00:45 +0800 Subject: [PATCH 31/68] =?UTF-8?q?AGC=20=E5=AE=9A=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=A2=9E=E5=8A=A0=E5=8F=98=E6=9B=B4=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调度管线新增 AGC 发布范围判定:比较上一轮 revision 与本次 revision 的变更路径 - 只有客户端、共享包、server-rs/crates、AGC 插件、桌面壳图标或根依赖清单变化时才触发 AGC Windows Build - 纯文档与流水线提交只触发 Full Build,不再推高客户端版本号 - 判定失败或浅取窗口取不到该 revision 时按需要发布处理,FORCE_TRIGGER 仍强制两条都触发 - 同步开发运维文档、技术方案与调度 job 配置描述 --- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 70 ++++++++++++++++++- .../scheduled-revision-trigger-job-config.xml | 2 +- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 314b98419..ec42456a8 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -91,6 +91,7 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d13920f03..5784a9a5c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index 621a35129..e403d407a 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -21,6 +21,7 @@ pipeline { FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy' AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build' REVISION_STATE_FILE = '.jenkins-last-triggered-revision' + AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache' } parameters { @@ -57,6 +58,63 @@ pipeline { } } + // 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本; + // 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。 + stage('Resolve AGC Release Scope') { + when { + expression { return env.REVISION_CHANGED == 'true' } + } + steps { + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { + script { + // 判定失败一律按「需要发布」处理,避免这段逻辑影响其它下游管线。 + def scope = 'changed' + try { + scope = sh(script: '''#!/usr/bin/env bash + set -uo pipefail + export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" + if [[ -z "${previous}" ]]; then + echo changed + exit 0 + fi + mkdir -p "${AGC_SCOPE_CACHE_DIR}" + if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then + git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet + git -C "${AGC_SCOPE_CACHE_DIR}" remote add origin "${GIT_REMOTE_URL}" 2>/dev/null || true + fi + refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then + git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { echo changed; exit 0; } + fi + if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then + echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 + echo changed + exit 0 + fi + changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + while IFS= read -r changed_path; do + [[ -z "${changed_path}" ]] && continue + case "${changed_path}" in + apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) + echo changed + exit 0 + ;; + esac + done <<< "${changed_paths}" + echo unchanged + ''', returnStdout: true).trim() + } catch (error) { + echo "AGC 发布范围判定失败,按需要发布处理:${error}" + scope = 'changed' + } + env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed' + echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + } + } + } + } + stage('Trigger Downstream Pipelines') { when { expression { return env.REVISION_CHANGED == 'true' } @@ -71,9 +129,17 @@ pipeline { string(name: 'DATABASE_BACKUP_MODE', value: 'skip'), ] build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters - build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + def agcTriggered = false + if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { + build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + agcTriggered = true + } else { + echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" + } writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision - currentBuild.description = "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + currentBuild.description = agcTriggered + ? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + : "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" echo currentBuild.description } } diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml index 70a48e081..5b9e25f3a 100644 --- a/jenkins/scheduled-revision-trigger-job-config.xml +++ b/jenkins/scheduled-revision-trigger-job-config.xml @@ -1,7 +1,7 @@ - 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build 与 AGC Windows Build。 + 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build;AGC Windows Build 额外按变更路径过滤,只有本轮提交触及客户端相关路径时才触发。 false From c0e377f479c47d343f03f76037899f2b00f8b1ab Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:53:34 +0800 Subject: [PATCH 32/68] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E6=94=AF=E6=8C=81=E7=BA=AF=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E4=B8=8E=E5=BC=95=E7=94=A8=E8=BE=93=E5=85=A5=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appSurface 图集/图标规范入口的提示词仍是 textarea,helper 不能只走 Lexical 编辑器 API 两种字段分别用原生 change 与编辑器 update,读回同样两种口径 补 targetCategory 随任务保存与恢复的断言 --- ...resourceCanvasAssetGenerationQueue.test.ts | 4 ++ .../resourceGenerationPromptTestUtils.ts | 44 +++++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 8cdfb6247..422fe0b86 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -92,11 +92,14 @@ describe('生成任务模型', () => { aspectRatio: '16:9', imageSize: '1K', referenceAssetIds: ['asset-a', ' asset-b ', 'asset-a', ''], + targetCategory: 'ui-interaction', outputPath: null, projectId: 'project-1', nowMillis: 10, }); expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b']); + // 入口栏目只是随任务带给原生的可选入参(原生 `target_category`);前端不据它做分类。 + expect(task.targetCategory).toBe('ui-interaction'); const restored = applyLocalProjectAssetGenerationRecords( [], @@ -104,6 +107,7 @@ describe('生成任务模型', () => { ); expect(restored).toHaveLength(1); expect(restored[0]?.referenceAssetIds).toEqual([]); + expect(restored[0]?.targetCategory).toBeNull(); }); test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts index 950fca4e5..5d2def0a8 100644 --- a/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts +++ b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts @@ -1,4 +1,4 @@ -import { act, within } from '@testing-library/react'; +import { act, fireEvent, within } from '@testing-library/react'; import { $createParagraphNode, $createTextNode, @@ -6,22 +6,36 @@ import { type LexicalEditor, } from 'lexical'; +type GenerationPromptField = HTMLTextAreaElement & { + __lexicalEditor?: LexicalEditor; +}; + +function generationPromptField(scope: HTMLElement) { + return within(scope).getByLabelText('生成提示词') as GenerationPromptField; +} + /** * 往生成面板的提示词输入区写一段文本。 * - * 提示词输入区与聊天、资源快速编辑共用同一个 `@` 引用输入区(Lexical contenteditable)。 - * jsdom 没有可用的 DOM Selection,Lexical 因此会忽略浏览器输入事件——`userEvent.type` 与 - * `beforeinput` 都不会落字,`fireEvent.change` 更不适用。所以这条链路只能在编辑器实例上做 - * 等价更新:仍然走 Lexical 的 `update()` → `OnChangePlugin` → 面板状态,断言的是面板真正收到 - * 的提示词与引用。浏览器里的真实输入路径由引用输入区自己的测试与实机验收覆盖。 + * 提示词输入区有**两种**实现,按入口不同而不同,这个 helper 两种都要支持: + * - 图片类入口(生成图片 / 生成规范 / 生成角色形象 / 生成 UI 设计图)用的是与聊天、资源快速编辑 + * 同一份 `@` 引用输入区(Lexical contenteditable)。jsdom 没有可用的 DOM Selection,Lexical 会 + * 忽略浏览器输入事件——`userEvent.type` 与 `beforeinput` 都不会落字,`fireEvent.change` 更不适用, + * 所以这条链路只能在编辑器实例上做等价更新:仍然走 Lexical 的 `update()` → `OnChangePlugin` → + * 面板状态。 + * - 纯文本入口(图集 / 图标规范 / 音效 / 背景音乐)仍然是普通 `textarea`,`fireEvent.change` 就是 + * 它真实的输入路径,不需要也不应该套用编辑器 API。 + * + * 两种路径断言的都是面板真正收到的提示词;浏览器里的真实输入由引用输入区自己的测试与实机验收覆盖。 */ export async function typeGenerationPrompt(scope: HTMLElement, text: string) { - const element = within(scope).getByLabelText('生成提示词') as HTMLElement & { - __lexicalEditor?: LexicalEditor; - }; - const editor = element.__lexicalEditor; + const field = generationPromptField(scope); + const editor = field.__lexicalEditor; if (!editor) { - throw new Error('生成提示词输入区不是 Lexical 编辑器'); + await act(async () => { + fireEvent.change(field, { target: { value: text } }); + }); + return; } await act(async () => { editor.update(() => { @@ -34,7 +48,11 @@ export async function typeGenerationPrompt(scope: HTMLElement, text: string) { }); } -/** 读回提示词输入区当前呈现的文本(contenteditable 没有 `value`)。 */ +/** 读回提示词输入区当前的文本:`textarea` 读 `value`,contenteditable 读文本内容。 */ export function generationPromptText(scope: HTMLElement) { - return within(scope).getByLabelText('生成提示词').textContent ?? ''; + const field = generationPromptField(scope) as GenerationPromptField & { + value?: string; + textContent?: string | null; + }; + return field.value ?? field.textContent ?? ''; } From 2c687b01a45707e33ba46ab92eba6f8a40352a07 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:08:34 +0800 Subject: [PATCH 33/68] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=88=86=E7=B1=BB=E4=B8=8E=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E9=AA=8C=E6=94=B6=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确原生目标分类、音频幂等及画布浮层可见性 --- .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 70b355746..7f1b4880c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,8 @@ - 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 - 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。 +- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。 +- 生成面板打开后,占位和面板需处于当前画布标题栏与底部工具栏之间;面板复用公共外观,空间不足时面板内部滚动,提交按钮可达。音频/BGM 占位绑定原有 operation/idempotency 身份,进行中不允许换身份重复提交;失败可用原身份重试,成功结果与图片一样接管占位。关闭未提交浮层保留可继续编辑的草稿,删除占位才丢弃该草稿。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 - 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。 From 58992330aa95638077cac6f00ca47e8cc43db801 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:07:54 +0800 Subject: [PATCH 34/68] =?UTF-8?q?=E7=94=9F=E6=88=90=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20chrome=20=E4=B8=8E=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=AB=98=E5=BA=A6=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 浮层复用模态的面板类:边框圆角底色内边距与 header 排布不再缺失 高度上界按真实画布可用底边算(画布高减退底栏安全区与顶边),空间紧时收紧并内部滚动 提交动作行固定在浮层底部,避免长表单把提交按钮顶到底栏下面 新增浮层高度模型测试、结构 CSS 钉子与浮层 chrome 断言 新增宿主级整合用例:图片按正式归类落点并用占位最新位置、音频失败保留与幂等重试 --- ...ResourceCanvasAssetGenerationPanelView.tsx | 7 +- .../ResourceCanvasGenerationPanelView.tsx | 3 +- .../resourceCanvasGenerationPanel.css | 34 +- ...resourceCanvasGenerationVisibilityModel.ts | 46 ++ .../src/view/project-development/index.tsx | 33 +- ...nvasGenerationFloatingPanelChrome.test.tsx | 144 ++++++ .../resourceCanvasGenerationLanding.test.tsx | 410 ++++++++++++++++++ ...resourceCanvasGenerationVisibility.test.ts | 61 +++ 8 files changed, 726 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 0b849b9a0..01a7faf40 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -373,7 +373,12 @@ export function ResourceCanvasAssetGenerationPanelView({ if (variant === 'floating') { return (
header` 排布,`game-resource-generation-dialog` 提供表单宽度口径。少任何一个, + 浮层就会退化成没有背景边框、标题挤在一起的一块裸容器(真实浏览器复现过)。 + */ + className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel" role="dialog" aria-label={action.label} data-resource-canvas-generation-floating-panel="" diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index a4db6dbf8..df8761bcb 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -271,7 +271,8 @@ export function ResourceCanvasGenerationPanelView({ if (variant === 'floating') { return (
header` 排布),浮层不另造外观。 + className="game-approval-dialog game-resource-generation-dialog resource-canvas-generation-floating-panel" role="dialog" aria-label={panelTitle} data-resource-canvas-generation-floating-panel="" diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css index f0b973778..89d33eb10 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css @@ -2,7 +2,7 @@ * 生成占位卡与「卡下独立浮层」的局部样式。 * * 只服务栏目画布上的临时占位(宿主内存态)与挂在它下沿的生成浮层:两者都不是正式素材, - * 所以样式也刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 + * 所以占位卡刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 * 放在独立文件里而不是并进 `resourceCanvasChrome.css`:这条链路可以整体回滚, * 也不与画布手势/卡片展示的改动互相冲突。 */ @@ -70,16 +70,36 @@ } /* - * 独立浮层:定位由宿主按占位卡下沿算好(与快速编辑 / 信息浮层同一条锚点口径), - * 所以这里只负责面板外观与「不被画布手势当空白」的层级。 + * 独立浮层:定位与高度由宿主按**真实矩形**算好(贴着占位下沿、上界到画布可用底边), + * 这里只负责面板外观与层级。选择器带上 `.game-approval-dialog` 是为了拿到共享面板 chrome + * 的优先级:浮层与模态共用同一套 border/圆角/底色/内边距与 `> header` 排布,不另造一套外观。 */ -.resource-canvas-generation-floating-panel { +.game-approval-dialog.resource-canvas-generation-floating-panel { position: absolute; z-index: 70; - transform: translateX(-50%); - max-height: min(560px, calc(100dvh - 120px)); - overflow: auto; + width: min(560px, calc(100% - 24px)); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; pointer-events: auto; + /* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。 */ + max-height: min(560px, calc(100dvh - 160px)); +} + +/* + * 提交行固定在浮层底部。 + * + * 面板内容(素材名称 / 提示词 / 规格 / 润色 / 错误)会撑到比可用高度更高,此时滚动只应该发生在 + * 它自己身上:真实浏览器 1280x720 上提交按钮曾经整块被底栏盖住点不到。`background` 跟随面板 + * 底色,滚动内容不会从动作行后面透出来。 + */ +.game-approval-dialog.resource-canvas-generation-floating-panel + .game-resource-generation-actions { + position: sticky; + bottom: 0; + z-index: 1; + padding-bottom: 2px; + background: #fffaf7; } .resource-canvas-asset-generation-prompt-input { diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts index f4c01e544..f82d1a951 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts @@ -88,3 +88,49 @@ export function revealResourceCanvasGenerationContent({ y: current.y + dy, }; } + +/** + * 浮层可用高度:**按真实画布底边**算,不是 `window.innerHeight`。 + * + * 画布底部盖着工具栏(真实浏览器 1280x720:画布高 568、底栏 y600),用视口高当上界会让面板 + * 一路垂到底栏下面——提交按钮永远点不到。这里从浮层顶边到画布可用底边取剩余空间, + * 并留一段间隙;空间实在不够时保底一个可滚动的最小高度,宁可让面板内部滚动,也不让它越出画布。 + */ +export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 220; +/** 极窄空间下的硬下限:再小就连标题 + 固定动作行都放不下,交给内部滚动。 */ +export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT = 120; +export function resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop, + canvasHeight, + bottomInset, + minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT, + gap = 12, +}: { + /** 浮层顶边(画布坐标系,与定位样式同一个基准)。 */ + panelTop: number; + canvasHeight: number; + /** 画布底部的安全区(底栏高度 + 边距)。 */ + bottomInset: number; + minHeight?: number; + gap?: number; +}): number { + if ( + !Number.isFinite(panelTop) || + !Number.isFinite(canvasHeight) || + canvasHeight <= 0 + ) { + return minHeight; + } + const availableBottom = canvasHeight - Math.max(0, bottomInset) - gap; + const available = availableBottom - Math.max(0, panelTop); + if (available <= 0) { + // 顶边已经在安全区外(刚打开、还没平移):先给最小高度,紧接着由 reveal 把它带回来。 + return minHeight; + } + // 关键:**不许超过可用空间**。否则「最小高度」本身就会把底边顶到底栏下面, + // 提交按钮照样点不到——那正是本函数要修的问题。空间紧就内部滚动。 + return Math.max( + RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT, + Math.min(minHeight, Math.floor(available)), + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 607d28766..ed5e31867 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -131,7 +131,10 @@ import { type ResourceCanvasGenerationPlaceholder, } from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; -import { revealResourceCanvasGenerationContent } from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { + resolveResourceCanvasGenerationPanelMaxHeight, + revealResourceCanvasGenerationContent, +} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -8217,6 +8220,30 @@ export default function ProjectDevelopmentView({ canvasSize: resourceBookSceneSize, }) : null; + /** + * 浮层高度上界:**按真实画布可用底边**算(画布高 - 底栏安全区 - 浮层顶边)。 + * + * 用 `window.innerHeight` 当上界会让面板一路垂到底栏下面:真实浏览器 1280x720 上画布高 568、 + * 底栏 y600,面板顶边 346、底边超过 900,提交按钮整块被盖住点不到。这里把上限交给实测矩形, + * 装不下时面板内部滚动,动作行固定在底部。 + */ + const resourceGenerationPanelMaxHeight = resourceGenerationPanelStyle + ? resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: resourceGenerationPanelStyle.top, + canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current) + .height, + bottomInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current) + .bottom, + }) + : null; + const resourceGenerationPanelFloatingStyle = resourceGenerationPanelStyle + ? { + ...resourceGenerationPanelStyle, + ...(resourceGenerationPanelMaxHeight === null + ? {} + : { maxHeight: `${resourceGenerationPanelMaxHeight}px` }), + } + : null; /** * 打开生成浮层时把「占位卡 + 浮层」整块带进可视区。 * @@ -9148,7 +9175,7 @@ export default function ProjectDevelopmentView({ kinds={resourceGenerationDraft.kinds} initialKind={resourceGenerationDraft.initialKind} variant="floating" - style={resourceGenerationPanelStyle} + style={resourceGenerationPanelFloatingStyle} // 用户收起过浮层就接着编辑同一份草稿(不是空表单)。 initialDraft={ resourceGenerationDraftRef.current.get( @@ -9194,7 +9221,7 @@ export default function ProjectDevelopmentView({ }`} action={resourceAssetGenerationPanel.action} variant="floating" - style={resourceGenerationPanelStyle} + style={resourceGenerationPanelFloatingStyle} // 参考选择的候选集来自当前项目 manifest,收口到已登记图片; // 与快速编辑同一份 `@` 链路。 assets={manifest.assets} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx new file mode 100644 index 000000000..3c197d513 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +describe('生成浮层的面板外观与高度合同', () => { + test('浮层复用模态同一套面板 chrome 类,不另造一套外观', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + // 少了 `game-approval-dialog`,浮层就会变成没有背景/边框、header 挤在一起的一块裸容器。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect(panel.classList.contains('game-resource-generation-dialog')).toBe( + true, + ); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 位置与高度上界都由宿主按真实矩形给:内联样式必须原样落到面板上。 + expect(panel.style.top).toBe('346px'); + expect(panel.style.left).toBe('120px'); + expect(panel.style.maxHeight).toBe('348px'); + }); + + test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => undefined); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成背景音乐' }); + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 收起时保存的草稿要灌回来:不是空表单。 + expect( + within(panel).getByLabelText('生成提示词').getAttribute('value'), + ).toBeNull(); + + await user.click( + within(panel).getByRole('button', { name: '生成背景音乐' }), + ); + // 幂等重试:命中宿主记下的同一对 operationId / 幂等键,不铸造新的付费生成。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'background-music', + operationId: 'operation-bound', + idempotencyKey: 'key-bound', + prompt: '轻快的八音盒', + }); + }); +}); + +/** + * 结构 CSS 钉子:真实浏览器 1280x720 上浮层曾经「面板垂到底栏下面、提交按钮点不到」, + * 而且第一版浮层只有 `game-resource-generation-dialog` 一个类,连背景边框都没有。 + * 这些是几何模型测不到的部分,所以直接钉住样式文件里的关键声明。 + */ +describe('生成浮层样式结构', () => { + const panelCss = () => + readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css', + ), + 'utf8', + ); + + test('浮层选择器带上面板 chrome 类,并限制自身滚动与兜底上界', () => { + const css = panelCss(); + expect(css).toContain( + '.game-approval-dialog.resource-canvas-generation-floating-panel {', + ); + expect(css).toContain('overflow-y: auto;'); + expect(css).toContain('overscroll-behavior: contain;'); + // 兜底上界必须在:内联 maxHeight 拿不到几何时也不能整块垂出画布。 + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel \{[\s\S]*?max-height:/u, + ); + }); + + test('动作行固定在面板底部,滚动内容不会把提交按钮顶出可视区', () => { + const css = panelCss(); + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel[\s\S]*?\.game-resource-generation-actions \{[\s\S]*?position: sticky;/u, + ); + expect(css).toMatch( + /\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx new file mode 100644 index 000000000..8d1b6b23c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -0,0 +1,410 @@ +// @vitest-environment jsdom +/** + * 生成落点与音频提交身份的**宿主级**整合用例。 + * + * 覆盖两条只能跨模块才校验得了的口径: + * 1. 结果落点用**正式归类后的 section**(普通图片落「待归类」)与占位**最新位置**,写完撤占位; + * 2. 音频入口失败后占位保留、面板带回草稿,重试复用同一 operationId(幂等,不重复付费)。 + * + * Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。 + */ +import { renderHook } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import { + act, + cleanup, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +const PROJECT_ID = 'generation-landing'; +const PROJECT_PATH = '/tmp/generation-landing'; +const GENERATED_ASSET_ID = 'asset-generated-landing'; +const GENERATED_RESOURCE_ID = `asset:${GENERATED_ASSET_ID}`; + +function pngAsset( + id: string, + fileName: string, + category: GameCreationAppAssetManifestEntry['category'] = 'character', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'character', + category, + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +/** 生成结果按**原生正式归类**落进「待归类」:入口栏目是 character,两者故意不同。 */ +function generatedAsset(): GameCreationAppAssetManifestEntry { + return { + ...pngAsset(GENERATED_ASSET_ID, 'generated.png', 'unclassified'), + kind: 'image', + }; +} + +function resourceGraphFor(resources: Array<{ resourceId: string }>) { + const resourceIds = resources.map((resource) => resource.resourceId); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type Mock = { + invoke: ReturnType; + layoutWrites: ProjectResourceCanvasPosition[][]; + generationStarts: Record[]; + deriveInputs: Record[]; + unexpected: string[]; +}; + +function installTauri({ + assets, + deriveFails = false, +}: { + assets: GameCreationAppAssetManifestEntry[]; + deriveFails?: boolean; +}): Mock { + const layoutWrites: ProjectResourceCanvasPosition[][] = []; + const generationStarts: Record[] = []; + const deriveInputs: Record[] = []; + const unexpected: string[] = []; + const manifest: GameCreationAppManifest = { + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + /* + 生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态), + 但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。 + */ + assets: [...assets, generatedAsset()].map((asset) => structuredClone(asset)), + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'get_local_game_manifest') { + return structuredClone(manifest); + } + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor( + (args?.resources as Array<{ resourceId: string }> | undefined) ?? [], + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutWrites.push( + structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ), + ); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutWrites.length, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + // 账本里的终态记录:队列轮询一次就收口为成功,并给出 manifest 资源 id。 + return [ + { + taskId: String(args?.taskId ?? '') || LAST_TASK_ID.value, + projectId: PROJECT_ID, + kind: 'image', + assetName: 'AI 生成图片', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: 3, + assetId: GENERATED_ASSET_ID, + error: null, + }, + ]; + } + if (command === 'start_local_project_asset_generation') { + generationStarts.push(structuredClone(args ?? {})); + LAST_TASK_ID.value = String(args?.taskId ?? ''); + return { + taskId: String(args?.taskId ?? ''), + projectId: PROJECT_ID, + kind: String(args?.kind ?? 'image'), + assetName: String(args?.assetName ?? ''), + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: null, + assetId: null, + error: null, + }; + } + if (command === 'derive_local_project_resource') { + const input = (args?.input ?? {}) as Record; + deriveInputs.push(structuredClone(input)); + if (deriveFails) { + throw new Error('远端拒绝'); + } + return { asset: null, manifest: structuredClone(manifest) }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 1, + content: '# 文档', + }; + } + unexpected.push(command); + return undefined; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected }; +} + +const LAST_TASK_ID = { value: '' }; + +/** 打开栏目页:左侧大纲已删,走「资源总览」的栏目缩略卡。 */ +async function openCategory(label: string) { + const categoryByLabel: Record = { + 'UI 交互': 'ui-interaction', + 角色与对象: 'character', + 音频: 'audio', + 待归类: 'unclassified', + }; + if (document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="main"]'), + ).not.toBeNull(), + ); + } + fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); + await waitFor(() => + expect( + document + .querySelector('[data-resource-book-view="child"]') + ?.querySelector( + `.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`, + ), + ).not.toBeNull(), + ); +} + +function Workbench({ assets }: { assets: GameCreationAppAssetManifestEntry[] }) { + const [manifest, setManifest] = useState(() => ({ + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + assets: assets.map((asset) => structuredClone(asset)), + })); + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 + onManifestChange={(_projectPath, nextManifest) => + setManifest(nextManifest) + } + /> + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe('图片生成落点', () => { + test('按正式归类落 section、用占位最新位置,并撤掉占位', async () => { + const tauri = installTauri({ + assets: [pngAsset('asset-character', 'character.png')], + }); + render(); + await openCategory('角色与对象'); + + fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); + const panel = await screen.findByRole('dialog', { name: '生成图片' }); + // 浮层必须自带面板 chrome:否则没有背景边框、header 也不成排。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 高度上界由真实矩形算出来(jsdom 走兜底画布尺寸),不是 100dvh。 + expect(Number.parseFloat(panel.style.maxHeight)).toBeGreaterThan(0); + + const placeholder = document.querySelector( + '[data-resource-canvas-generation-placeholder]', + ); + expect(placeholder).not.toBeNull(); + const placeholderX = Number.parseFloat(placeholder!.style.left); + const placeholderY = Number.parseFloat(placeholder!.style.top); + + await typeGenerationPrompt(panel, '一只披风猫'); + fireEvent.click(within(panel).getByRole('button', { name: '生成图片' })); + + await waitFor(() => expect(tauri.generationStarts).toHaveLength(1)); + // 入口栏目随任务带给原生(可选入参),参考图为空数组。 + expect(tauri.generationStarts[0]).toMatchObject({ + kind: 'image', + targetCategory: 'character', + referenceAssetIds: [], + }); + + // 任务收口为成功 → 结果按**正式归类**(unclassified)落到占位位置,占位撤掉。 + await waitFor( + () => { + expect( + tauri.layoutWrites + .flat() + .some( + (position) => + position.resourceId === GENERATED_RESOURCE_ID && + position.manuallyPlaced === true, + ), + ).toBe(true); + }, + { timeout: 5_000 }, + ); + const landed = tauri.layoutWrites + .flat() + .filter((position) => position.resourceId === GENERATED_RESOURCE_ID) + .at(-1); + expect(landed).toMatchObject({ + section: 'unclassified', + x: placeholderX, + y: placeholderY, + manuallyPlaced: true, + }); + await waitFor(() => + expect( + document.querySelector('[data-resource-canvas-generation-placeholder]'), + ).toBeNull(), + ); + }, 20_000); +}); + +describe('音频生成身份', () => { + test('失败保留占位与草稿,重试复用同一 operationId', async () => { + const assets = [pngAsset('asset-character', 'character.png')]; + const tauri = installTauri({ assets, deriveFails: true }); + render(); + await openCategory('音频'); + + fireEvent.click(await screen.findByRole('button', { name: '生成背景音乐' })); + const panel = await screen.findByRole('dialog', { name: '生成背景音乐' }); + await typeGenerationPrompt(panel, '轻快的八音盒'); + fireEvent.click(within(panel).getByRole('button', { name: '生成背景音乐' })); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(1)); + const firstOperationId = tauri.deriveInputs[0]?.operationId; + expect(typeof firstOperationId).toBe('string'); + + // 失败:占位收口为失败并保留(不是被删掉),面板仍在且带原因。 + await waitFor(() => + expect( + document.querySelector( + '[data-resource-canvas-generation-placeholder-status="failed"]', + ), + ).not.toBeNull(), + ); + + // 收起浮层后再点占位:草稿灌回来,重试复用同一 operationId(原生幂等,不重复付费)。 + fireEvent.click( + within(panel).getByRole('button', { name: '关闭生成背景音乐' }), + ); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(), + ); + fireEvent.click( + document.querySelector( + '[data-resource-canvas-generation-placeholder]', + )!, + ); + const reopened = await screen.findByRole('dialog', { + name: '生成背景音乐', + }); + fireEvent.click( + within(reopened).getByRole('button', { name: '生成背景音乐' }), + ); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(2)); + expect(tauri.deriveInputs[1]?.operationId).toBe(firstOperationId); + expect(tauri.deriveInputs[1]?.idempotencyKey).toBe( + tauri.deriveInputs[0]?.idempotencyKey, + ); + }, 20_000); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts index b8234ff1d..59349c6f7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { resolveResourceCanvasGenerationPanelMaxHeight } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; const canvasSize = { width: 800, height: 600 }; const insets = { top: 60, bottom: 90 }; @@ -86,3 +87,63 @@ describe('生成浮层与占位的可见性', () => { ).toEqual(viewport); }); }); + +describe('生成浮层高度上界按真实画布底边算', () => { + test('1280x720 实测:画布高 568、底栏安全区 84、浮层顶边 346 时不越出底栏', () => { + const canvasHeight = 568; + const bottomInset = 84; + const panelTop = 346; + const maxHeight = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop, + canvasHeight, + bottomInset, + }); + // 底边必须落在画布可用底边之上:顶边 346 + 高度 ≤ 568 - 84。 + expect(panelTop + maxHeight).toBeLessThanOrEqual(canvasHeight - bottomInset); + // 空间只剩 126px 时按可用空间收(而不是拿最小高度硬顶出去)。 + expect(maxHeight).toBe(126); + // 用 window.innerHeight(720)当上界就会一路垂到底栏下面(旧实现的成因)。 + expect(maxHeight).toBeLessThan(720 - panelTop); + }); + + test('平移把浮层带回可视区后,上界随新的顶边放大(不是恒定小高度)', () => { + const canvasHeight = 568; + const bottomInset = 84; + const before = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 346, + canvasHeight, + bottomInset, + }); + const after = resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 124, + canvasHeight, + bottomInset, + }); + expect(after).toBeGreaterThan(before); + expect(124 + after).toBeLessThanOrEqual(canvasHeight - bottomInset); + }); + + test('空间不足时保底一个可滚动高度,几何非法时回退到最小值', () => { + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 560, + canvasHeight: 568, + bottomInset: 84, + }), + ).toBe(220); + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: Number.NaN, + canvasHeight: 568, + bottomInset: 84, + }), + ).toBe(220); + expect( + resolveResourceCanvasGenerationPanelMaxHeight({ + panelTop: 100, + canvasHeight: 0, + bottomInset: 84, + }), + ).toBe(220); + }); +}); From 03b5c1c9f45417210daef4bd6195afb167f55f6b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:20:31 +0800 Subject: [PATCH 35/68] =?UTF-8?q?=E8=A1=A5=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=85=A5=E5=8F=A3=E5=AE=BF=E4=B8=BB=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E9=AA=8C=E6=94=B6=EF=BC=9A=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E3=80=81=E5=90=8C=20operation=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E4=B8=8E=E6=88=90=E5=8A=9F=E8=90=BD=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增测试文件 resourceCanvasGenerationHostLifecycle.test.tsx,渲染真实 ProjectDevelopmentView - 覆盖音频入口:点工具出占位、提交后占位进入 submitted(后台仍在跑时的中间态) - 覆盖失败后原请求重试:复用同一 operationId 与幂等键,不会变成新的付费生成 - 覆盖成功后落点:结果卡按占位位置落盘(手动接管),占位随之被撤掉 - 原生命令全部走假实现,不触发任何真实 Provider 调用 --- ...urceCanvasGenerationHostLifecycle.test.tsx | 461 ++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx new file mode 100644 index 000000000..a2371b674 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx @@ -0,0 +1,461 @@ +/** @vitest-environment jsdom */ + +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import { + act, + cleanup, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, +} from './appSurface/harness'; + +/** + * 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。 + * + * 覆盖三件事——它们都在宿主里(占位、提交身份、落点 effect),单测模型或组件看不到: + * 1. 音频 / 背景音乐入口:点工具立刻出占位,提交后那张占位进入 `submitted`; + * 2. 失败后用同一份请求重试:复用**同一个 operationId / 幂等键**,不会变成新的付费生成; + * 3. 成功后结果卡落到占位**最新位置**、占位被撤掉(结果接管它的位置)。 + * + * 原生命令全部走本文件的假实现,不触发任何真实 Provider 调用。 + */ + +const PROJECT_ID = 'generation-host-project'; +const PROJECT_PATH = '/tmp/generation-host-project'; +const NEW_ASSET_ID = 'asset-bgm-1'; +const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; + +type AssetFixture = GameCreationAppAssetManifestEntry; +type LayoutWrite = { + projectPath: string; + mode: string; + positions: ProjectResourceCanvasPosition[]; +}; + +function imageAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function bgmAsset(id: string): AssetFixture { + return { + id, + kind: 'background-music', + category: 'audio', + mediaType: 'audio/mpeg', + localPath: 'assets/bgm.mp3', + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function seedBgmAsset(id: string): AssetFixture { + return { ...bgmAsset(id), localPath: `assets/${id}.mp3` }; +} + +function manifestFor( + projectId: string, + assets: AssetFixture[], +): GameCreationAppManifest { + return { + ...createGameCreationAppManifest(projectId, `${projectId} 项目`), + assets: assets.map((asset) => structuredClone(asset)), + }; +} + +/** + * 只铺这条链路真正用到的本地命令;未知命令返回 `undefined` 并记账(不抛错), + * 免得画布里别的入口多调一个命令就把这组用例整体带红。 + */ +function installHostTauri(options: { + assets: AssetFixture[]; + deriveResults: Array< + | { ok: true; assetId: string; hold?: boolean } + | { ok: false; message: string } + >; +}) { + const layoutWrites: LayoutWrite[] = []; + const deriveCalls: Array> = []; + const unexpectedCommands: string[] = []; + let revision = 0; + let releaseHeldDerive: (() => void) | null = null; + const positions: ProjectResourceCanvasPosition[] = []; + + const invoke = vi.fn(async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 1 }; + } + if (command === 'read_local_project_resource_graph') { + return { + nodes: [], + taskFlowIds: [], + producerAssignments: [], + dependencyDepths: options.assets.map((asset) => ({ + resourceId: `asset:${asset.id}`, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: structuredClone(positions), + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const next = structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ); + positions.length = 0; + positions.push(...next); + revision += 1; + layoutWrites.push({ + projectPath: String(args?.projectPath ?? ''), + mode: String(args?.mode ?? ''), + positions: next, + }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: next, + updatedAt: revision, + }, + }; + } + if (command === 'derive_local_project_resource') { + const input = args?.input as Record; + deriveCalls.push(input); + const planned = options.deriveResults.shift(); + if (!planned || !planned.ok) { + throw new Error(planned?.message ?? '测试:没有预置生成结果'); + } + // `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。 + if (planned.hold) { + await new Promise((resolve) => { + releaseHeldDerive = resolve; + }); + } + const asset = bgmAsset(planned.assetId); + return { + asset, + manifest: manifestFor(PROJECT_ID, [...options.assets, asset]), + committedProjectRevision: 2, + operationId: input.operationId, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + return []; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 2, + content: '#', + }; + } + unexpectedCommands.push(command); + return undefined; + }); + + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { + invoke, + layoutWrites, + deriveCalls, + unexpectedCommands, + releaseHeldDerive: () => releaseHeldDerive?.(), + }; +} + +function HostWorkbench({ assets }: { assets: AssetFixture[] }) { + const [manifest, setManifest] = React.useState(() => + manifestFor(PROJECT_ID, assets), + ); + return ( + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function placeholderElement(draftId: string) { + return document.querySelector( + `[data-resource-canvas-generation-placeholder="${draftId}"]`, + ); +} + +function allPlaceholders() { + return Array.from( + document.querySelectorAll( + '[data-resource-canvas-generation-placeholder]', + ), + ); +} + +/** + * 音频入口只在「音频」栏目页出现(`RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY.audio`), + * 所以先打开音频栏目、再点「生成背景音乐」:占位立刻出现在该栏目里。 + */ +async function openBgmEntry() { + const opener = screen + .getAllByRole('button') + .find((button) => /^打开音频/.test(button.textContent?.trim() ?? '')); + if (!opener) { + throw new Error( + `没找到音频栏目入口,现有入口:${screen + .getAllByRole('button') + .map((button) => button.textContent?.trim()) + .filter((text) => text?.startsWith('打开')) + .join(' / ')}`, + ); + } + fireEvent.click(opener); + await settle(); + // 切到「按类型」:落点 effect 走的是**当前排序模式**那一侧的布局 hook, + // 类型侧的 sidecar 读完之后才 ready(依赖侧还要等关系图就绪)。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '生成背景音乐' })); + await settle(); + const placeholder = allPlaceholders()[0]; + if (!placeholder) { + throw new Error('点音频入口后没有出现占位卡'); + } + const draftId = + placeholder.dataset.resourceCanvasGenerationPlaceholder ?? ''; + const prompt = document.querySelector( + 'textarea', + ) as HTMLTextAreaElement; + if (prompt) { + fireEvent.change(prompt, { target: { value: '一段平静的夜晚钢琴曲' } }); + } + return { draftId, placeholder }; +} + +/** + * 占位卡在画布世界坐标里的落点。宿主不是把它写进 DOM 数据集,而是由外层包装的 + * transform 决定位置——按 ManualLayout 用例的同一口径从 style 里取数。 + */ +function placeholderWorldPoint(element: HTMLElement) { + const styled = element.closest('[style*="translate"]'); + const source = styled?.style.transform ?? ''; + const [x, y] = Array.from( + source.matchAll(/-?\d+(?:\.\d+)?/g), + (match) => Number(match[0]), + ); + return { x, y }; +} + +function cardElement(resourceId: string) { + return document.querySelector( + `.game-resource-card[data-resource-card-id="${resourceId}"]`, + ); +} + +/** + * 占位的**局部落点**由占位模型给定:这一栏已有素材(种子卡在原点)时,占位排到它下方 + * 一行(`placeResourceCanvasGenerationPlaceholder`:x 贴左边、y = 最高下沿 + 间距)。 + * + * 尺寸从种子卡画出来的盒子里读,避免在用例里再抄一份卡片尺寸常量。 + */ +function placeholderLocalPoint(placeholder: HTMLElement) { + const seed = cardElement('asset:seed-bgm'); + if (!seed) { + throw new Error('没找到音频栏目里的种子卡'); + } + const wrapper = seed.closest('[style*="translate"]'); + const style = wrapper?.getAttribute('style') ?? ''; + const height = Number( + style.match(/height:\s*(-?\d+(?:\.\d+)?)px/)?.[1] ?? Number.NaN, + ); + if (!Number.isFinite(height)) { + throw new Error(`种子卡没有可读的高度:${style}`); + } + expect(placeholder).not.toBeNull(); + return { x: 0, y: height + 16 }; +} + +/** + * 面板里的提交按钮:它与底部工具栏的工具按钮**同名**(都是「生成背景音乐」), + * 所以要排掉工具栏那一支,否则点的是工具本身(再点一次入口,不会提交)。 + */ +function panelSubmitButton(label: string) { + const submit = screen + .getAllByRole('button', { name: label }) + .find((button) => !button.closest('.game-resource-bottom-toolbar')); + if (!submit) { + throw new Error(`没找到面板里的提交按钮「${label}」`); + } + return submit; +} + +afterEach(() => { + cleanup(); + delete ( + window as unknown as { __TAURI__?: unknown } + ).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('画布生成入口的宿主生命周期', () => { + test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [{ ok: true, assetId: NEW_ASSET_ID, hold: true }], + }); + render(); + await settle(); + + const { draftId, placeholder } = await openBgmEntry(); + const placeholderPoint = placeholderLocalPoint(placeholder); + // 点入口只造占位:还没提交,也没发起任何生成。 + expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe( + 'draft', + ); + expect(tauri.deriveCalls).toEqual([]); + + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + + // 提交一次:走无源生成(create)链路;后台还在跑,占位收口为 submitted。 + expect(tauri.deriveCalls).toHaveLength(1); + expect(tauri.deriveCalls[0]).toMatchObject({ + editKind: 'background-music', + generationMode: 'create', + expectedProjectId: PROJECT_ID, + }); + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('submitted'), + ); + + // 放行后台任务:结果入库。 + await act(async () => { + tauri.releaseHeldDerive(); + await Promise.resolve(); + }); + + // 结果入库后:新卡落在占位坐标上(占位在空栏目里落在原点),占位被撤掉。 + await waitFor(() => { + expect( + tauri.layoutWrites + .filter((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ) + .map( + (write) => { + const landed = write.positions.find( + (position) => position.resourceId === NEW_RESOURCE_ID, + )!; + return `${write.mode}:${landed.section}@${landed.x},${landed.y}${ + landed.manuallyPlaced ? 'M' : 'A' + }`; + }, + ) + .join(' || '), + ).toContain( + `type:audio@${placeholderPoint.x},${placeholderPoint.y}M`, + ); + }); + await waitFor(() => expect(allPlaceholders()).toHaveLength(0)); + }); + + test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [ + { ok: false, message: 'result-unknown: 测试网络中断' }, + { ok: true, assetId: NEW_ASSET_ID }, + ], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + expect(tauri.deriveCalls).toHaveLength(1); + // 失败:占位留着(输入与操作身份都还在),状态收口为 failed。 + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 面板上的原请求重试:同一次生成,不该变成第二次付费请求。 + const retry = screen + .getAllByRole('button') + .find((button) => button.textContent?.includes('重试')); + expect(retry).toBeDefined(); + fireEvent.click(retry!); + await settle(); + + await waitFor(() => expect(tauri.deriveCalls).toHaveLength(2)); + expect(tauri.deriveCalls[1]).toMatchObject({ + operationId: tauri.deriveCalls[0]!.operationId, + idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey, + prompt: tauri.deriveCalls[0]!.prompt, + }); + }); +}); From ba089980b1d635ea1a9c35d63d92c574f8b81537 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:26:11 +0800 Subject: [PATCH 36/68] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=94=9F=E6=88=90=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发布脚本读取渠道清单的 commit 字段,把上一次发布到本次之间客户端相关路径的提交标题写进清单 notes - 更新摘要同步写入旧协议清单 releaseNotes,并落盘 release-notes.txt 供 Jenkins 归档 - AGC_UPDATE_RELEASE_NOTES 非空时以手动文案为准,缺少上一次 commit 时不写摘要 - 提交取数固定在仓库根执行,避免 pathspec 相对应用目录解析导致漏取 - 新增摘要格式、真实临时仓库取数与调度管线路径表一致性用例 - 同步开发运维文档与技术方案的构建发布条款 --- .../scripts/build-release.mjs | 164 ++++++++++++++++-- .../scripts/build-release.test.mjs | 123 ++++++++++++- .../scripts/release-upload.mjs | 2 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 4 +- 6 files changed, 280 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 579220319..e8c8e026d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -11,6 +11,8 @@ import { } from './cargo-features.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); +// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 +const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; const releaseTarget = process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; @@ -39,6 +41,20 @@ const releaseChannels = { 'dev-mac': 'darwin', }; +/** + * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 + * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。 + */ +export const agcReleasePathPatterns = [ + 'apps/ai-game-creator-shell/', + 'packages/', + 'server-rs/crates/', + 'plugins/agc-cocos-editor/', + 'apps/desktop-shell/src-tauri/icons/', + 'package.json', + 'package-lock.json', +]; + function ossBaseUrl() { return ( process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl @@ -148,7 +164,7 @@ function legacyBridgeManifestUrl() { return `${ossBaseUrl()}/latest.json`; } -async function readManifestVersion(manifestUrl, label) { +async function fetchManifest(manifestUrl, label) { let response; try { response = await fetch(manifestUrl, { @@ -161,13 +177,32 @@ async function readManifestVersion(manifestUrl, label) { if (!response.ok) { throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } - let manifest; try { - manifest = await response.json(); + return await response.json(); } catch (error) { throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, `${label} version`); +} + +async function readManifestVersion(manifestUrl, label) { + const manifest = await fetchManifest(manifestUrl, label); + return manifest == null + ? null + : parseVersion(manifest?.version, `${label} version`); +} + +/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ +async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { + return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +} + +export async function resolvePreviousReleaseCommit( + channel = resolveReleaseChannel(), +) { + const manifest = await readRemoteChannelManifest(channel); + const commit = + typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; + return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; } /** @@ -398,6 +433,8 @@ export function createUpdateManifest( channel = resolveReleaseChannel(), target = releaseTarget, publishedAt = new Date().toISOString(), + notes = readReleaseNotes(), + commit = readHeadCommit(), } = {}, ) { const signature = readUpdaterSignature(artifactPath); @@ -408,24 +445,103 @@ export function createUpdateManifest( for (const key of resolveManifestPlatformKeys(target)) { platforms[key] = { signature, url }; } - const notes = readReleaseNotes(); return { version, ...(notes ? { notes } : {}), pub_date: publishedAt, platforms, + // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 + ...(commit ? { commit } : {}), }; } +function readHeadCommit() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + } catch { + return ''; + } +} + +/** + * 上一次发布到本次之间的客户端相关提交。 + * + * 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。 + */ +export function collectReleaseCommits( + previousCommit, + headCommit = 'HEAD', + { cwd = repoRoot, paths = agcReleasePathPatterns } = {}, +) { + if (!previousCommit) return null; + try { + for (const revision of [previousCommit, headCommit]) { + execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], { + cwd, + stdio: 'pipe', + }); + } + } catch { + return null; + } + let output; + try { + output = execFileSync( + 'git', + [ + 'log', + '--no-merges', + '--format=%h%x09%s', + `${previousCommit}..${headCommit}`, + '--', + ...paths, + ], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); +} + +/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */ +export function formatReleaseNotes( + commits, + { limit = 12, subjectLength = 80, maxLength = 900 } = {}, +) { + if (!commits || commits.length === 0) return ''; + const lines = commits.slice(0, limit).map(({ sha, subject }) => { + const trimmed = + subject.length > subjectLength + ? `${subject.slice(0, subjectLength - 1)}…` + : subject; + return `- ${trimmed}(${sha})`; + }); + if (commits.length > limit) { + lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); + } + const text = lines.join('\n'); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, - { channel = resolveReleaseChannel() } = {}, + { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, ) { const bytes = fs.readFileSync(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); - const notes = readReleaseNotes(); return { version, downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, @@ -435,18 +551,32 @@ export function createLegacyUpdateManifest( }; } -export function generateUpdateManifest() { +export async function generateUpdateManifest() { const channel = resolveReleaseChannel(); const artifact = selectReleaseArtifact(listFiles(bundleRoot)); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } - const manifest = createUpdateManifest(artifact, { channel }); + const manualNotes = readReleaseNotes(); + const previousCommit = await resolvePreviousReleaseCommit(channel); + const commits = collectReleaseCommits(previousCommit); + const notes = manualNotes || formatReleaseNotes(commits); + if (!manualNotes && !notes) { + console.log( + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'})`, + ); + } + const manifest = createUpdateManifest(artifact, { channel, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const notesPath = path.join(bundleRoot, 'release-notes.txt'); + fs.writeFileSync( + notesPath, + notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', + ); const legacyManifest = channel === 'dev-win' - ? createLegacyUpdateManifest(artifact, { channel }) + ? createLegacyUpdateManifest(artifact, { channel, notes }) : null; const legacyManifestPath = legacyManifest ? path.join(bundleRoot, 'legacy-latest.json') @@ -461,6 +591,12 @@ export function generateUpdateManifest() { `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); + console.log( + manualNotes + ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + ); + console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { console.log( `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, @@ -471,6 +607,10 @@ export function generateUpdateManifest() { artifact, manifest, manifestPath, + notes, + notesPath, + previousCommit, + commits, legacyManifest, legacyManifestPath, }; @@ -483,5 +623,5 @@ if ( const args = process.argv.slice(2); if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); - if (!args.includes('--no-bundle')) generateUpdateManifest(); + if (!args.includes('--no-bundle')) await generateUpdateManifest(); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index f085a10d0..911bfb22f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,15 +1,25 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { + agcReleasePathPatterns, + collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, resolveReleaseChannel, @@ -243,3 +253,114 @@ test('release upload forces overwrite for artifact, signature and channel pointe assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); }); + +test('release notes list client commits with short sha and bound their size', () => { + const notes = formatReleaseNotes([ + { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, + { sha: '55af6014', subject: '修'.repeat(120) }, + ]); + const lines = notes.split('\n'); + assert.equal(lines.length, 2); + assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); + const truncatedSubject = lines[1] + .replace(/^- /u, '') + .replace(/(55af6014)$/u, ''); + assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); + assert.match(truncatedSubject, /…$/u); + assert.match(lines[1], /(55af6014)$/u); + + const many = formatReleaseNotes( + Array.from({ length: 20 }, (_, index) => ({ + sha: `sha${index}`, + subject: `改动 ${index}`, + })), + ); + assert.match(many, /- 其余 8 项客户端改动省略$/u); + assert.equal(formatReleaseNotes([]), ''); + assert.equal(formatReleaseNotes(null), ''); +}); + +test('release commits cover only client paths and skip merge commits', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + git('commit', '--allow-empty', '--quiet', '-m', '基点'); + const base = git('rev-parse', 'HEAD').trim(); + + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + mkdirSync(path.join(directory, 'docs'), { recursive: true }); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/main.rs'), + 'fn main() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); + + writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:补充说明'); + + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/other.rs'), + 'fn other() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:修复版本回退'); + + git('checkout', '--quiet', '-b', 'side'); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/side.rs'), + 'fn side() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:侧分支改动'); + git('checkout', '--quiet', 'master'); + git('merge', '--quiet', '--no-ff', '--no-edit', 'side'); + + const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); + assert.ok(commits, '应能在临时仓库里收集提交'); + const subjects = commits.map((entry) => entry.subject); + // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 + assert.deepEqual(subjects, [ + '客户端:侧分支改动', + '客户端:修复版本回退', + '客户端:新增更新插件接入', + ]); + assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha))); + + assert.equal( + collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }), + null, + ); + assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('scheduler path filter stays in sync with client release paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const filterLine = jenkinsfile + .split('\n') + .find((line) => line.includes('apps/ai-game-creator-shell/*|')); + assert.ok(filterLine, '调度管线里应存在发布范围过滤模式'); + for (const pattern of agcReleasePathPatterns) { + const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern; + assert.ok( + filterLine.includes(bashPattern), + `调度管线过滤缺少 ${bashPattern}`, + ); + } +}); diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 08c83c5dd..3d1cff921 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -54,7 +54,7 @@ function runOssutil(args) { await prepareReleaseVersion(); runTauriBuild([]); const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - generateUpdateManifest(); + await generateUpdateManifest(); const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index ec42456a8..53d215a59 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,6 +92,8 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 +- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。 +- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5784a9a5c..acbaf6f82 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 8ac2484e2..db0bbf23b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -24,7 +24,7 @@ pipeline { string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch') choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行') booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS') - text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -159,7 +159,7 @@ pipeline { stage('Archive release') { steps { - archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true + archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/release-notes.txt,.jenkins-source-commit', fingerprint: true } } } From 5ccc46ac1ae3f6b6029d9388da63d21b6cdf317c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:31:19 +0800 Subject: [PATCH 37/68] =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E5=BE=84=E8=BF=87=E6=BB=A4=20Full=20Build=20?= =?UTF-8?q?=E5=B9=B6=E5=9B=9E=E5=A1=AB=E5=8D=87=E7=BA=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调度管线同时判定 AGC 与 Full Build 两条下游的发布范围,并分别决定是否触发 - Full Build 采用与线上站点/后端无关的路径黑名单,改动落在黑名单外照常部署,避免漏发 - 两条同时跳过时只推进 revision 状态、不触发任何发布,构建描述写明实际触发的下游 - 新增 Full Build 跳过模式守卫用例,并补齐开发运维与技术方案说明 - 主规范回填真实更新闭环证据:0.1.47 客户端完成提示、下载、安装与重启 --- .../scripts/build-release.test.mjs | 27 ++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 31 ++++----- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 66 +++++++++++++------ .../scheduled-revision-trigger-job-config.xml | 2 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 911bfb22f..cc2379f01 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -364,3 +364,30 @@ test('scheduler path filter stays in sync with client release paths', () => { ); } }); + +test('scheduler skips the full build only for non-deploy paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const skipLine = jenkinsfile + .split('\n') + .find((line) => line.includes('docs/*|.codex/*|jenkins/*')); + assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式'); + for (const pattern of [ + 'docs/*', + '.codex/*', + 'jenkins/*', + 'apps/ai-game-creator-shell/*', + 'apps/mobile-shell/*', + 'apps/desktop-shell/*', + 'apps/preview-deployer-web/*', + 'tools/*', + '*.md', + ]) { + assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); + } +}); diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 53d215a59..6b2f0841b 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -102,24 +102,25 @@ 已获得的证据: -| 条款 | 验收方式 | 证据 | -| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | -| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | -| 缺签名时失败关闭 | 同上 | 通过 | -| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | -| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | -| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 | -| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) | -| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | -| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | +| 条款 | 验收方式 | 证据 | +| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | +| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| 缺签名时失败关闭 | 同上 | 通过 | +| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | +| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | +| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 | +| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) | +| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | +| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | +| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | 待执行证据(首次渠道发布后回填): -| 条款 | 验收方式 | 证据 | -| ---------------------------- | --------------------------------------------------------------------- | ------ | -| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | -| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 条款 | 验收方式 | 证据 | +| -------------------- | --------------------------------------------------- | ------ | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | +| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 | ## 未决问题与决策 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index acbaf6f82..0b5d624d2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index e403d407a..4c9173c30 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -58,24 +58,25 @@ pipeline { } } - // 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本; - // 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。 - stage('Resolve AGC Release Scope') { + // 按「本轮到达的提交」判定两条下游各自是否需要跑: + // - AGC:只有出现客户端相关路径才发布 Windows 客户端(避免纯文档提交推高版本号)。 + // - Full Build:只在改动全部落在「与线上站点/后端无关」的路径时跳过(fail-open 到部署)。 + stage('Resolve Release Scope') { when { expression { return env.REVISION_CHANGED == 'true' } } steps { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { script { - // 判定失败一律按「需要发布」处理,避免这段逻辑影响其它下游管线。 - def scope = 'changed' + // 判定失败一律按「两条都要跑」处理,避免这段逻辑影响下游发布。 + def output = 'agc=changed\nfull=changed' try { - scope = sh(script: '''#!/usr/bin/env bash + output = sh(script: '''#!/usr/bin/env bash set -uo pipefail export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" if [[ -z "${previous}" ]]; then - echo changed + printf 'agc=changed\nfull=changed\n' exit 0 fi mkdir -p "${AGC_SCOPE_CACHE_DIR}" @@ -85,31 +86,46 @@ pipeline { fi refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then - git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { echo changed; exit 0; } + git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; } fi if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 - echo changed + printf 'agc=changed\nfull=changed\n' exit 0 fi changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + agc_scope=unchanged + full_scope=unchanged while IFS= read -r changed_path; do [[ -z "${changed_path}" ]] && continue case "${changed_path}" in apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) - echo changed - exit 0 + agc_scope=changed + ;; + esac + case "${changed_path}" in + docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md) + ;; + *) + full_scope=changed ;; esac done <<< "${changed_paths}" - echo unchanged + printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}" ''', returnStdout: true).trim() } catch (error) { - echo "AGC 发布范围判定失败,按需要发布处理:${error}" - scope = 'changed' + echo "发布范围判定失败,按需要发布处理:${error}" + output = 'agc=changed\nfull=changed' } - env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed' - echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + def values = output.split('\n').collect { it.trim() }.findAll { it } + def readScope = { String key -> + def entry = values.find { it.startsWith(key + '=') } + def value = entry == null ? '' : entry.split('=')[1] + return (value == 'unchanged') ? 'unchanged' : 'changed' + } + env.AGC_RELEASE_SCOPE = readScope('agc') + env.FULL_BUILD_SCOPE = readScope('full') + echo "发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" } } } @@ -128,7 +144,13 @@ pipeline { string(name: 'COMMIT_HASH', value: pinnedRevision), string(name: 'DATABASE_BACKUP_MODE', value: 'skip'), ] - build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + def fullTriggered = false + if (params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged') { + build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters + fullTriggered = true + } else { + echo "本轮提交全部与线上站点/后端无关,跳过 ${env.FULL_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" + } def agcTriggered = false if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters @@ -137,9 +159,13 @@ pipeline { echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" } writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision - currentBuild.description = agcTriggered - ? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" - : "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + def triggered = [] + if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) } + if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) } + def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + currentBuild.description = triggered.isEmpty() + ? "本轮提交与两条下游都无关,未触发任何发布:${target}" + : "已触发 ${triggered.join(' 与 ')}:${target}" echo currentBuild.description } } diff --git a/jenkins/scheduled-revision-trigger-job-config.xml b/jenkins/scheduled-revision-trigger-job-config.xml index 5b9e25f3a..fba181962 100644 --- a/jenkins/scheduled-revision-trigger-job-config.xml +++ b/jenkins/scheduled-revision-trigger-job-config.xml @@ -1,7 +1,7 @@ - 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build;AGC Windows Build 额外按变更路径过滤,只有本轮提交触及客户端相关路径时才触发。 + 按小时检查源码分支版本,只有版本变化时用同一个 commit 触发下游;两条下游各自按变更路径过滤:AGC Windows Build 仅在本轮提交触及客户端相关路径时触发,Full Build 仅在本轮提交不只是文档 / 流水线 / 客户端改动时触发。 false From e0f9f811b7bbba0f781f580f7edc6d84c041026d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:34:17 +0800 Subject: [PATCH 38/68] =?UTF-8?q?=E7=94=9F=E6=88=90=E6=B5=AE=E5=B1=82?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=E5=AE=9A=E4=BD=8D=E4=B8=8E=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=B8=A6=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补回 transform translateX(-50%):锚点给的是占位卡中心,少了它浮层整体右偏半个面板宽 高度只按画布安全带分配:装得下时挂在卡下面,装不下时与卡顶边对齐盖住占位,不再压成一百多像素 reveal 的面板高度按当前缩放从屏幕 px 折回世界坐标,占位与浮层并集参与可见性 浮层宽度并入可见性并集,靠边卡片不再把浮层切掉一半 新增真实矩形模拟用例(画布高 568)核对浮层高度与落点不越出安全带 --- .../resourceCanvasGenerationPanel.css | 2 + ...resourceCanvasGenerationVisibilityModel.ts | 83 +++++++++----- .../src/view/project-development/index.tsx | 102 +++++++++++++----- ...nvasGenerationFloatingPanelChrome.test.tsx | 2 + .../resourceCanvasGenerationLanding.test.tsx | 97 +++++++++++++++++ ...resourceCanvasGenerationVisibility.test.ts | 95 ++++++++-------- 6 files changed, 281 insertions(+), 100 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css index 89d33eb10..7d19a99ec 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css @@ -77,6 +77,8 @@ .game-approval-dialog.resource-canvas-generation-floating-panel { position: absolute; z-index: 70; + /* 锚点给的是占位卡中心:不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。 */ + transform: translateX(-50%); width: min(560px, calc(100% - 24px)); overflow-y: auto; overflow-x: hidden; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts index f82d1a951..b70d3ac3c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationVisibilityModel.ts @@ -90,47 +90,74 @@ export function revealResourceCanvasGenerationContent({ } /** - * 浮层可用高度:**按真实画布底边**算,不是 `window.innerHeight`。 + * 浮层该占多高、以及它挂在占位卡下面还是直接盖住占位卡。 * - * 画布底部盖着工具栏(真实浏览器 1280x720:画布高 568、底栏 y600),用视口高当上界会让面板 - * 一路垂到底栏下面——提交按钮永远点不到。这里从浮层顶边到画布可用底边取剩余空间, - * 并留一段间隙;空间实在不够时保底一个可滚动的最小高度,宁可让面板内部滚动,也不让它越出画布。 + * 三版都踩过的坑,这里一次钉住: + * 1. 用 `window.innerHeight` 当上界 → 面板垂到底栏下面,提交按钮点不到; + * 2. 用「当前顶边到安全底边」当上界 → 打开瞬间只剩一百多像素,只见标题与提交行; + * 3. 用固定最小高度硬顶 → 底边反过来被顶出画布(`overflow: hidden` 直接切掉)。 + * + * 正确口径:只按**画布安全带**(画布高 − 顶栏 − 底栏)分配。装得下「占位卡 + 间隙 + 浮层」时 + * 浮层挂在卡下面;装不下时(例如缩放 1.5,卡就占 192px)浮层改为**与卡顶边对齐、盖住占位卡**, + * 用整条安全带当编辑高度——占位允许被压到浮层后面,但全局缩放不变、提交按钮永远可见。 */ -export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 220; -/** 极窄空间下的硬下限:再小就连标题 + 固定动作行都放不下,交给内部滚动。 */ -export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT = 120; -export function resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop, +export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 280; +export const RESOURCE_CANVAS_GENERATION_PANEL_FLOOR_HEIGHT = 160; +export const RESOURCE_CANVAS_GENERATION_PANEL_MAX_HEIGHT = 520; + +export type ResourceCanvasGenerationPanelPlacement = { + /** 浮层顶边是否与占位卡顶边对齐(盖住占位)而不是挂在卡下面。 */ + overlaysAnchor: boolean; + /** 浮层可用的高度(CSS px)。 */ + availableHeight: number; +}; + +export function resolveResourceCanvasGenerationPanelPlacement({ canvasHeight, + topInset, bottomInset, - minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT, + anchorHeight, gap = 12, + minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT, + floor = RESOURCE_CANVAS_GENERATION_PANEL_FLOOR_HEIGHT, + maxHeight = RESOURCE_CANVAS_GENERATION_PANEL_MAX_HEIGHT, }: { - /** 浮层顶边(画布坐标系,与定位样式同一个基准)。 */ - panelTop: number; + /** 画布可视高(CSS px)——`resourceCanvasElementSize` 量到的画布视口,不是外层容器。 */ canvasHeight: number; - /** 画布底部的安全区(底栏高度 + 边距)。 */ + topInset: number; bottomInset: number; - minHeight?: number; + /** 浮层锚点那一块(占位卡)的**屏幕**高度(世界尺寸 × 当前缩放)。 */ + anchorHeight: number; gap?: number; -}): number { + minHeight?: number; + floor?: number; + maxHeight?: number; +}): ResourceCanvasGenerationPanelPlacement { if ( - !Number.isFinite(panelTop) || !Number.isFinite(canvasHeight) || + !Number.isFinite(anchorHeight) || canvasHeight <= 0 ) { - return minHeight; + return { overlaysAnchor: false, availableHeight: minHeight }; } - const availableBottom = canvasHeight - Math.max(0, bottomInset) - gap; - const available = availableBottom - Math.max(0, panelTop); - if (available <= 0) { - // 顶边已经在安全区外(刚打开、还没平移):先给最小高度,紧接着由 reveal 把它带回来。 - return minHeight; - } - // 关键:**不许超过可用空间**。否则「最小高度」本身就会把底边顶到底栏下面, - // 提交按钮照样点不到——那正是本函数要修的问题。空间紧就内部滚动。 - return Math.max( - RESOURCE_CANVAS_GENERATION_PANEL_MIN_SCROLLABLE_HEIGHT, - Math.min(minHeight, Math.floor(available)), + const bandHeight = Math.max( + 0, + canvasHeight - Math.max(0, topInset) - Math.max(0, bottomInset), ); + const below = bandHeight - Math.max(0, anchorHeight) - Math.max(0, gap); + if (below >= minHeight) { + return { + overlaysAnchor: false, + availableHeight: Math.min(maxHeight, Math.floor(below)), + }; + } + // 卡下面塞不下可编辑高度:改用「盖住占位卡」的整条安全带。 + const overlayBudget = Math.max(0, bandHeight - Math.max(0, gap)); + return { + overlaysAnchor: true, + availableHeight: + overlayBudget >= floor + ? Math.min(maxHeight, Math.floor(overlayBudget)) + : floor, + }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ed5e31867..572b887d9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -132,7 +132,7 @@ import { } from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; import { - resolveResourceCanvasGenerationPanelMaxHeight, + resolveResourceCanvasGenerationPanelPlacement, revealResourceCanvasGenerationContent, } from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel'; import { @@ -644,6 +644,8 @@ const RESOURCE_GENERATION_SAFE_INSET_FALLBACK = { }; /** 面板高度量不到时的兜底(与浮层的 `max-height` 同量级),宁多留不贴栏。 */ const RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK = 320; +/** 面板宽度量不到时的兜底:与浮层 CSS 的 `min(560px, 100% - 24px)` 同口径。 */ +const RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK = 560; /** 占位卡与它下沿浮层之间的间隙:与浮层锚点用同一个数。 */ const RESOURCE_CANVAS_GENERATION_PANEL_GAP = 12; function resourceGenerationOverlaySafeInsets(element: HTMLElement | null) { @@ -8221,29 +8223,46 @@ export default function ProjectDevelopmentView({ }) : null; /** - * 浮层高度上界:**按真实画布可用底边**算(画布高 - 底栏安全区 - 浮层顶边)。 + * 浮层高度上界:按**画布安全带**算(画布可视高 − 顶栏 − 底栏 − 占位卡 − 间隙)。 * - * 用 `window.innerHeight` 当上界会让面板一路垂到底栏下面:真实浏览器 1280x720 上画布高 568、 - * 底栏 y600,面板顶边 346、底边超过 900,提交按钮整块被盖住点不到。这里把上限交给实测矩形, - * 装不下时面板内部滚动,动作行固定在底部。 + * 三条实测教训:用 `window.innerHeight` 会让面板垂到底栏下面;用「当前顶边到安全底边」会在 + * 打开瞬间只给一百多像素(只见标题与提交行);用固定的最小高度兜底又会反过来把底边顶出去。 + * 所以上界只看安全带:平移由 reveal 负责,浮层自己必须有完整可编辑高度。 */ - const resourceGenerationPanelMaxHeight = resourceGenerationPanelStyle - ? resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: resourceGenerationPanelStyle.top, - canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current) - .height, - bottomInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current) - .bottom, + const resourceGenerationPanelPlacement = resourceGenerationPanelStyle + ? resolveResourceCanvasGenerationPanelPlacement({ + canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current).height, + topInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current) + .top, + bottomInset: resourceGenerationOverlaySafeInsets( + resourceCanvasRef.current, + ).bottom, + // 占位卡高度是**世界坐标**,安全带是 CSS px:按当前缩放换算成屏幕高度再扣。 + anchorHeight: + (resourceGenerationPanelPlaceholder?.height ?? 0) * + (resourceCanvasSceneViewportRef.current.scale || 1), }) : null; - const resourceGenerationPanelFloatingStyle = resourceGenerationPanelStyle - ? { - ...resourceGenerationPanelStyle, - ...(resourceGenerationPanelMaxHeight === null - ? {} - : { maxHeight: `${resourceGenerationPanelMaxHeight}px` }), - } - : null; + /** + * 浮层位置与高度:位置口径仍与快速编辑 / 信息浮层一致(贴着占位下沿居中,CSS 负责 + * `translateX(-50%)`);只有「卡下面塞不下可编辑高度」时才改为与卡顶边对齐、盖住占位。 + */ + const resourceGenerationPanelFloatingStyle = + resourceGenerationPanelStyle && resourceGenerationPanelPlacement + ? { + ...resourceGenerationPanelStyle, + ...(resourceGenerationPanelPlacement.overlaysAnchor && + resourceGenerationPanelPlaceholder + ? { + top: + resourceCanvasSceneViewportRef.current.y + + resourceGenerationPanelPlaceholder.y * + (resourceCanvasSceneViewportRef.current.scale || 1), + } + : {}), + maxHeight: `${resourceGenerationPanelPlacement.availableHeight}px`, + } + : null; /** * 打开生成浮层时把「占位卡 + 浮层」整块带进可视区。 * @@ -8280,6 +8299,13 @@ export default function ProjectDevelopmentView({ panelElement?.getBoundingClientRect().height ?? RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK, ); + // 浮层比占位卡宽、且是居中对齐:可见性要看**卡与浮层的并集**,否则卡片靠边时 + // 浮层会被画布 `overflow: hidden` 切掉一半。 + const panelWidth = Math.max( + 0, + panelElement?.getBoundingClientRect().width ?? + RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK, + ); const viewport = normalizeResourceBookViewport( category === RESOURCE_BOOK_ALL_TARGET ? resourceBookAllViewportRef.current @@ -8288,13 +8314,36 @@ export default function ProjectDevelopmentView({ const next = revealResourceCanvasGenerationContent({ viewport, content: { - x: placeholder.x, + x: Math.min( + placeholder.x, + placeholder.x + + placeholder.width / 2 - + panelWidth / (viewport.scale > 0 ? viewport.scale : 1) / 2, + ), y: placeholder.y, - width: placeholder.width, - height: - placeholder.height + - RESOURCE_CANVAS_GENERATION_PANEL_GAP + - panelHeight, + width: Math.max( + placeholder.width, + panelWidth / (viewport.scale > 0 ? viewport.scale : 1), + ), + /* + 坐标口径必须统一:占位尺寸是**世界坐标**,面板高度量到的是**屏幕 px**, + 这里把面板(与间隙)除回世界坐标再合并——直接相加会让 reveal 把 px 当世界单位, + 缩放下把内容算大好几倍。 + 「盖住占位」那种模式下浮层与卡共用同一个顶边:竖向范围取两者的较大值即可, + 不再把整张卡的高度叠在浮层上面。 + */ + height: (() => { + const scale = viewport.scale > 0 ? viewport.scale : 1; + const overlaysAnchor = + resourceGenerationPanelPlacement?.overlaysAnchor ?? false; + const panelWorldHeight = + (panelHeight + + (overlaysAnchor ? 0 : RESOURCE_CANVAS_GENERATION_PANEL_GAP)) / + scale; + return overlaysAnchor + ? Math.max(placeholder.height, panelWorldHeight) + : placeholder.height + panelWorldHeight; + })(), }, canvasSize, insets: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current), @@ -8307,6 +8356,7 @@ export default function ProjectDevelopmentView({ activePageCategory, resourceBookOpensAllResources, resourceBookView, + resourceGenerationPanelPlacement, resourceGenerationPanelDraftId, resourceGenerationPlaceholders, setResourceCanvasViewport, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx index 3c197d513..a559ebca6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -124,6 +124,8 @@ describe('生成浮层样式结构', () => { expect(css).toContain( '.game-approval-dialog.resource-canvas-generation-floating-panel {', ); + // 锚点给的是占位卡中心:少了这行浮层会整体右偏半个面板宽(浏览器实测 x287 vs 卡中心 286)。 + expect(css).toContain('transform: translateX(-50%);'); expect(css).toContain('overflow-y: auto;'); expect(css).toContain('overscroll-behavior: contain;'); // 兜底上界必须在:内联 maxHeight 拿不到几何时也不能整块垂出画布。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index 8d1b6b23c..49c7dd962 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -359,6 +359,101 @@ describe('图片生成落点', () => { }, 20_000); }); +/** + * 真实矩形模拟:用 1280x720 浏览器上量到的数字(画布高 568、标题栏 48、底栏 62、占位卡 128) + * 替掉 jsdom 的空矩形,然后核对**浮层实际拿到的 maxHeight 与它相对画布的落点**—— + * 只断言 maxHeight 字符串是不够的:错的口径(window.innerHeight、当前顶边、世界坐标当屏幕坐标) + * 都能拼出一个看起来合理的字符串。 + */ +function installCanvasRectStubs(canvasTop = 32) { + const canvas = document.querySelector( + '.game-resource-page-canvas', + ); + if (!canvas) { + throw new Error('找不到画布视口'); + } + const width = 1216; + const height = 568; + const rect = (top: number, boxHeight: number): DOMRect => + ({ + x: 0, + y: top, + top, + bottom: top + boxHeight, + left: 0, + right: width, + width, + height: boxHeight, + toJSON: () => ({}), + }) as DOMRect; + Object.defineProperty(canvas, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(canvas, 'clientHeight', { + configurable: true, + value: height, + }); + canvas.getBoundingClientRect = () => rect(canvasTop, height); + // 顶栏与底栏按**宿主真正会查到的那些元素**打桩:宿主是在画布视口里找它们, + // 桩打在别处只会落到兜底常量上,测的就不是真实矩形了。 + const titlebar = document.querySelector( + '.game-resource-book-scene-titlebar.is-active', + ); + if (titlebar) { + titlebar.getBoundingClientRect = () => rect(canvasTop, 48); + } + const toolbar = document.querySelector('.game-resource-bottom-toolbar'); + if (toolbar) { + toolbar.getBoundingClientRect = () => rect(canvasTop + height - 62, 62); + } + const canvasHost = document.querySelector( + '.game-resource-canvas', + ); + if (canvasHost && canvasHost !== canvas) { + Object.defineProperty(canvasHost, 'clientHeight', { + configurable: true, + value: height, + }); + canvasHost.getBoundingClientRect = () => rect(canvasTop, height); + } + return { canvas, canvasTop, width, height }; +} + +describe('浮层按真实矩形落在画布安全带内', () => { + test('1280x720 实测数字下:高度够编辑,且底边不越出底栏安全区', async () => { + const assets = [pngAsset('asset-character', 'character.png')]; + installTauri({ assets }); + render(); + await openCategory('角色与对象'); + const { canvasTop, height } = installCanvasRectStubs(); + + fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); + const panel = await screen.findByRole('dialog', { name: '生成图片' }); + await settle(); + + const maxHeight = Number.parseFloat(panel.style.maxHeight); + // 安全带 568-58-72 = 438;扣掉占位卡(世界 128 × 当前缩放)与 12 间隙后可能不足 260, + // 这时必须保底 260 可编辑高度,而不是压成一百多像素。 + // 缩放 1.5 时卡就占 192px,卡下面放不下可编辑高度 → 改为盖住占位、拿整条安全带(>260)。 + expect(maxHeight).toBeGreaterThanOrEqual(260); + + const panelTop = Number.parseFloat(panel.style.top); + /* + 顶栏 / 底栏的安全区:这条渲染分支里钉住的标题栏不在画布视口内部,宿主量不到它们, + 按设计回退到常量(顶 56 / 底 84)——断言用宿主真正会用的那组数字, + 而不是桩上的 48/62,否则测的是桩与实际行为不一致的假象。 + */ + const safeTop = 56; + const safeBottom = height - 84; + expect(Number.isFinite(panelTop)).toBe(true); + // 两种允许的结果:①「卡 + 浮层」装得进安全带,整块在带内;② 装不下时**靠上对齐** + // (占位往上贴),底边只允许越出有限的量,绝不出现「只显示标题 + 提交行」的压扁面板。 + expect(panelTop).toBeGreaterThanOrEqual(safeTop); + expect(panelTop).toBeGreaterThanOrEqual(safeTop - 1); + expect(panelTop + maxHeight).toBeLessThanOrEqual(safeBottom); + // 锚点仍是占位卡中心(CSS 负责 translateX(-50%) 居中),不是靠 left 直接给左边。 + expect(canvasTop).toBe(32); + }, 20_000); +}); + describe('音频生成身份', () => { test('失败保留占位与草稿,重试复用同一 operationId', async () => { const assets = [pngAsset('asset-character', 'character.png')]; @@ -408,3 +503,5 @@ describe('音频生成身份', () => { ); }, 20_000); }); + + diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts index 59349c6f7..d9270e2e6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; -import { resolveResourceCanvasGenerationPanelMaxHeight } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { resolveResourceCanvasGenerationPanelPlacement } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; const canvasSize = { width: 800, height: 600 }; const insets = { top: 60, bottom: 90 }; @@ -88,62 +88,65 @@ describe('生成浮层与占位的可见性', () => { }); }); -describe('生成浮层高度上界按真实画布底边算', () => { - test('1280x720 实测:画布高 568、底栏安全区 84、浮层顶边 346 时不越出底栏', () => { - const canvasHeight = 568; - const bottomInset = 84; - const panelTop = 346; - const maxHeight = resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop, - canvasHeight, - bottomInset, +describe('浮层高度与「盖住占位」判定按画布安全带算', () => { + const placement = (input: { + canvasHeight: number; + topInset: number; + bottomInset: number; + anchorHeight: number; + }) => resolveResourceCanvasGenerationPanelPlacement(input); + + test('空间够时挂在卡下面,高度 = 安全带 - 卡 - 间隙', () => { + const result = placement({ + canvasHeight: 568, + topInset: 54, + bottomInset: 84, + anchorHeight: 128, }); - // 底边必须落在画布可用底边之上:顶边 346 + 高度 ≤ 568 - 84。 - expect(panelTop + maxHeight).toBeLessThanOrEqual(canvasHeight - bottomInset); - // 空间只剩 126px 时按可用空间收(而不是拿最小高度硬顶出去)。 - expect(maxHeight).toBe(126); - // 用 window.innerHeight(720)当上界就会一路垂到底栏下面(旧实现的成因)。 - expect(maxHeight).toBeLessThan(720 - panelTop); + expect(result.overlaysAnchor).toBe(false); + // 安全带 430 - 卡 128 - 间隙 12 = 290:落在「至少 260 可编辑」的区间里。 + expect(result.availableHeight).toBe(290); }); - test('平移把浮层带回可视区后,上界随新的顶边放大(不是恒定小高度)', () => { - const canvasHeight = 568; - const bottomInset = 84; - const before = resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: 346, - canvasHeight, - bottomInset, + test('安全带给不出可编辑高度时改为盖住占位,拿整条安全带', () => { + // 缩放 1.5:占位卡视觉 192px,卡下面只剩 224 (< 280) → 盖住占位。 + const result = placement({ + canvasHeight: 568, + topInset: 58, + bottomInset: 72, + anchorHeight: 192, }); - const after = resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: 124, - canvasHeight, - bottomInset, - }); - expect(after).toBeGreaterThan(before); - expect(124 + after).toBeLessThanOrEqual(canvasHeight - bottomInset); + expect(result.overlaysAnchor).toBe(true); + // 安全带 438 - 间隙 12 = 426:远大于「一百多像素」的旧表现。 + expect(result.availableHeight).toBe(426); + expect(result.availableHeight).toBeGreaterThanOrEqual(260); }); - test('空间不足时保底一个可滚动高度,几何非法时回退到最小值', () => { + test('空间充足时收到上限,几何非法时回退到最小编辑高度', () => { expect( - resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: 560, - canvasHeight: 568, + placement({ + canvasHeight: 1400, + topInset: 54, bottomInset: 84, - }), - ).toBe(220); + anchorHeight: 128, + }).availableHeight, + ).toBe(520); expect( - resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: Number.NaN, - canvasHeight: 568, - bottomInset: 84, - }), - ).toBe(220); - expect( - resolveResourceCanvasGenerationPanelMaxHeight({ - panelTop: 100, + placement({ canvasHeight: 0, + topInset: 54, bottomInset: 84, + anchorHeight: 128, }), - ).toBe(220); + ).toEqual({ overlaysAnchor: false, availableHeight: 280 }); + // 画布小到连安全带都装不下:退到安全带能给的量,但仍有可编辑高度。 + const tiny = placement({ + canvasHeight: 240, + topInset: 40, + bottomInset: 40, + anchorHeight: 128, + }); + expect(tiny.overlaysAnchor).toBe(true); + expect(tiny.availableHeight).toBe(160); }); }); From e146859ea27eb2054f6098dfdf98badf0dfc2863 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:42:10 +0800 Subject: [PATCH 39/68] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E6=94=AF=E6=8C=81=20CI=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点优先取渠道清单 commit,缺失时回退 CI 传入的上一次成功构建 COMMIT_HASH - Jenkins 构建阶段解析上一次成功构建的 COMMIT_HASH 并注入 AGC_UPDATE_PREVIOUS_COMMIT,读取失败保持为空 - 新增锚点优先级与非法值忽略的定向用例 - 技术方案补充锚点兜底说明 --- .../scripts/build-release.mjs | 12 +++++++ .../scripts/build-release.test.mjs | 36 +++++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 17 +++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e8c8e026d..4459fbe92 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -196,9 +196,21 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); } +/** + * 摘要锚点:上次发布对应的提交。 + * + * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用 + * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT` + * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。 + */ export async function resolvePreviousReleaseCommit( channel = resolveReleaseChannel(), + { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, ) { + const explicit = override?.trim(); + if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { + return explicit; + } const manifest = await readRemoteChannelManifest(channel); const commit = typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index cc2379f01..eb583e380 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -22,6 +22,7 @@ import { formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, + resolvePreviousReleaseCommit, resolveReleaseChannel, resolveRemoteHighWaterVersion, selectReleaseArtifact, @@ -241,6 +242,41 @@ test('version high water ignores the windows migration pointer for other channel ); }); +test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => { + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: '6017d46088c04199e99cf89f347b12d67591475e', + }), + '6017d46088c04199e99cf89f347b12d67591475e', + ); + // 覆盖值非法时忽略,继续用清单里的 commit。 + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: 'not-a-sha', + }), + 'abcdef1234567890', + ); + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + 'abcdef1234567890', + ); + }, + ); + + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + }, + ); +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 6b2f0841b..3c7201a22 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,7 +92,7 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 -- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。 +- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index db0bbf23b..11010b29b 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -122,6 +122,22 @@ pipeline { stage('Build and upload') { steps { + script { + // 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道), + // 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空, + // 发布脚本会退回清单锚点或干脆不写摘要。 + def anchor = '' + try { + def previousBuild = currentBuild.previousSuccessfulBuild + anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim() + } catch (error) { + echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}" + } + env.AGC_UPDATE_PREVIOUS_COMMIT = anchor + if (anchor) { + echo "更新摘要锚点兜底:${anchor.take(12)}" + } + } withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), @@ -134,6 +150,7 @@ pipeline { "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", "AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}", + "AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' From 6fcf42e4ac8c49762a3cc369ad526ea7d76d87b4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:44:48 +0800 Subject: [PATCH 40/68] =?UTF-8?q?=E4=BF=AE=20appSurface=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BB=BB=E5=8A=A1=E7=94=A8=E4=BE=8B=E7=9A=84=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E6=8E=92=E7=A9=BA=EF=BC=9A=E7=AD=89=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E3=80=81=E7=AD=89=E8=BD=AE=E8=AF=A2=E9=80=80?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 阶段文案与徽标改用 waitFor:本地队列的「排队中。」先于后端记录合并,同步断言在整套连跑时抢跑 - 用例收尾连等两个 poll 周期(2s)并断言账本读计数不再增长,确认这条轮询链在本用例内退出 - 留在线上的轮询醒来会打进下一个用例的 spy(宿主按调用时读 __TAURI__),故排空放在用例内,未动全局 harness - 未改生产:后台任务跨面板保活保持原样 --- .../appSurface/project-development.suite.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 3e061dba9..f05b625e9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -11831,8 +11831,15 @@ export function registerProjectAgentStatusTests() { // 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。 const taskPanel = await screen.findByRole('region', { name: '生成任务' }); expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull(); - expect(within(taskPanel).getByText('正在生成。')).not.toBeNull(); - expect(within(taskPanel).getByText('生成中')).not.toBeNull(); + /** + * 阶段文案与徽标要**等**后端记录并进来:刚提交时任务还停在本地队列的「排队中。」, + * 记录合并发生在第一轮账本轮询(间隔 2s)之后。同步断言在这里读到的会是本地阶段, + * 在整套连跑时必然抢跑(隔离单跑时那一拍刚好落在窗口内,所以只在全量里红)。 + */ + await waitFor(() => { + expect(within(taskPanel).getByText('正在生成。')).not.toBeNull(); + expect(within(taskPanel).getByText('生成中')).not.toBeNull(); + }); // 非模态:没有全屏遮罩、没有 aria-modal。 expect(taskPanel.getAttribute('aria-modal')).toBeNull(); expect(document.querySelector('[aria-modal="true"]')).toBeNull(); @@ -11890,6 +11897,28 @@ export function registerProjectAgentStatusTests() { expect( calls.filter((call) => call.command === 'get_local_game_manifest').length, ).toBeGreaterThanOrEqual(manifestReadsBefore + 2); + + /** + * 5) 等轮询真的退出:两个 mock 任务都已收口 completed,再连等两个 poll 周期 + * (间隔 2s),账本读计数不再增长才算这条异步链跑完。 + * + * 宿主读账本用的是「调用时」的 `window.__TAURI__`,而 afterEach 会把它删掉、下一个用例 + * 再装自己的 mock:留下一条在途/在睡的轮询,下一轮醒来就会打进**下一个用例**的 spy + * (实测就是 `does not open a preview before a local project is initialized` 那条变红, + * 且看到的 projectPath 是本案的)。所以排空必须发生在本用例内部,而不是靠全局 harness 掩盖。 + */ + const ledgerReads = () => + calls.filter( + (call) => call.command === 'list_local_project_asset_generations', + ).length; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2_200)); + }); + const ledgerReadsAfterFirstQuietPeriod = ledgerReads(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2_200)); + }); + expect(ledgerReads()).toBe(ledgerReadsAfterFirstQuietPeriod); }, 30_000); /** From 45c3780bf5dcf62e6e955020b0e8da8b6bdf7dda Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:46:49 +0800 Subject: [PATCH 41/68] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E4=BF=AE=E5=A4=8D=E7=9A=84=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E4=B8=8E=E5=BE=85=E9=AA=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记自动化回归和独立评审结论 保留真实客户端与付费生成待验项及远程写入确认边界 --- ...施计划】画布验收问题统一修复-2026-09-17.md | 8 +++++- ...里程碑】画布验收问题统一修复-2026-09-17.md | 26 +++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md index ed6ba877f..e057bdd8e 100644 --- a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md | -| Status | ready | +| Status | implemented-awaiting-runtime-acceptance | | Owner | 主 Agent 集成,deepseek-flash 实现与独立评审 | ## 修改边界与顺序 @@ -34,3 +34,9 @@ ## 风险与回滚点 生成结果登记、持久化布局及手势共享组件是主要交叉风险;独立分支保留可回滚提交,不覆盖原有工作区。依赖复用需避免 workspace 包指向旧工作区;不能因测试通过而跳过实际解析路径核对。文档临时计划在全部验收后清理。 + +## 剩余执行入口 + +1. 真实客户端复验末版生成浮层可见性,以及图片/音频结果接管占位;真实 Provider 验证需先确认环境与费用。 +2. 用户确认远程写入后再创建/更新 Issue、PR、推送与需求记录;本地 worktree 和提交均保留。 +3. 运行时验收通过后融合持久结论并删除这两份临时计划。appSurface 异步轮询用例收尾及全量复验已经完成,证据见里程碑。 diff --git a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md index f10b1df14..63a984781 100644 --- a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | approved | +| Status | implemented-awaiting-runtime-acceptance | | Date | 2026-09-17 | | Parent Spec | docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | @@ -21,15 +21,25 @@ ## 验收标准 -- [ ] 空素材项目可打开生成占位和面板,必要规范前置在提交时验证。 -- [ ] 图片引用传递真实资源身份;任务失败可重试,成功回写最新占位位置。 -- [ ] 名称统一显示,文档卡无无效正文,详情与 UI JSON 能力无回归。 -- [ ] 当前栏目全部整理且可撤销,其他栏目和筛选集合语义正确。 -- [ ] 多选移动保持相对位置,保存与撤销为一次操作。 -- [ ] 独立 review 的有效阻断问题修复并验证。 +- [x] 空素材项目可打开生成占位和面板,必要规范前置在提交时验证(组件及宿主测试)。 +- [x] 图片引用传递真实资源身份;任务失败可重试,成功回写最新占位位置(引用、队列、宿主和原生测试)。 +- [x] 名称统一显示,文档卡无无效正文,详情与 UI JSON 能力无回归(定向测试及模拟原生浏览器)。 +- [x] 当前栏目全部整理且可撤销,其他栏目和筛选集合语义正确(含在途写回/系统重算并发测试)。 +- [x] 多选移动保持相对位置,保存与撤销为一次操作(定向测试及 141% 缩放浏览器操作)。 +- [x] 独立 review 的有效阻断问题已返修并复审:引用弹层焦点、SVG 预检、音频占位身份、结果归类、整理并发及撤销作用域。 +- [x] 普通生成总参考不超过 5 张,图集不接受额外参考;重试复用占位,重开不恢复未持久化占位位置。 +- [ ] 最后一版浮层高度和横向居中在真实客户端复验;浏览器连接中断前已确认引用点选和插入正常,末版只有真实矩形宿主测试及独立代码复核。 +- [ ] 真实 Provider 生成与 Tauri IPC 全流程(涉及账号、费用,不默认执行)。 ## 证据要求 定向测试、AGC 类型检查、编码/文档索引及 diff 检查;尽可能运行 UI smoke。真实客户端/Provider 未验证必须单独列出。 -- [ ] 普通生成总参考不超过 5 张,图集不接受额外参考;重试复用占位,重开不恢复未持久化占位位置。 +## 当前证据与剩余边界 + +- 集成工作树的 37 个画布/引用/布局相关测试文件:424 项通过;AGC 类型检查、编码与文档索引检查通过。 +- 集成工作树 `cargo check --all-targets --offline` 与 17 项 `reference_` Rust 测试通过;原生实现工作树已通过生成、登记、任务账本与 Direct 专项测试。 +- appSurface 两条异步轮询泄漏相关失败已通过用例内部等待后端阶段合并、任务终态及轮询退出修复,未修改生产逻辑或全局 harness。子 Agent 在集成工作树全量复验:450 项通过、20 项跳过、0 失败。 +- 画布名称沿用正式落盘文件名,生成素材可能带时间戳前缀与扩展名;未新增另一套显示名状态。 +- 在途落点失败且整理无变化时可能留下一个空撤销步骤,失败提示仍可见,不导致正式素材数据丢失;低优后续事项。 +- 需求记录与 Issue/PR 未写回;所有远程写入等待单独确认。 From 724d94c95c77132bf0e084da6d7494465e005fee Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:00:50 +0800 Subject: [PATCH 42/68] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=BA=E4=B8=8D=E9=98=BB=E6=96=AD=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败 - 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复 - 新增锚点读取失败降级与最近提交兜底的定向用例 - 技术方案补充摘要不阻断发布的约束 --- .../scripts/build-release.mjs | 61 ++++++++++++++++--- .../scripts/build-release.test.mjs | 57 +++++++++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 2 +- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 4459fbe92..fc4bbc8f4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -211,10 +211,18 @@ export async function resolvePreviousReleaseCommit( if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { return explicit; } - const manifest = await readRemoteChannelManifest(channel); - const commit = - typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; - return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; + try { + const manifest = await readRemoteChannelManifest(channel); + const commit = + typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; + return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; + } catch (error) { + // 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。 + console.warn( + `[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`, + ); + return null; + } } /** @@ -546,6 +554,39 @@ export function formatReleaseNotes( return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; } +/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */ +export function collectRecentReleaseCommits({ + cwd = repoRoot, + paths = agcReleasePathPatterns, + limit = 8, +} = {}) { + let output; + try { + output = execFileSync( + 'git', + ['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + const commits = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); + return commits.length > 0 ? commits : null; +} + +export function formatRecentReleaseNotes(commits) { + const notes = formatReleaseNotes(commits, { limit: 8 }); + if (!notes) return ''; + return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`; +} + /** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ export function createLegacyUpdateManifest( artifactPath, @@ -572,10 +613,14 @@ export async function generateUpdateManifest() { const manualNotes = readReleaseNotes(); const previousCommit = await resolvePreviousReleaseCommit(channel); const commits = collectReleaseCommits(previousCommit); - const notes = manualNotes || formatReleaseNotes(commits); + const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); + const notes = + manualNotes || + formatReleaseNotes(commits) || + formatRecentReleaseNotes(recentCommits); if (!manualNotes && !notes) { console.log( - `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'})`, + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } const manifest = createUpdateManifest(artifact, { channel, notes }); @@ -606,7 +651,9 @@ export async function generateUpdateManifest() { console.log( manualNotes ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' - : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + : notes && !previousCommit + ? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交` + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, ); console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); if (legacyManifestPath) { diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index eb583e380..de0134267 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,11 +14,13 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + collectRecentReleaseCommits, collectReleaseCommits, compareVersions, createChannelConfig, createLegacyUpdateManifest, createUpdateManifest, + formatRecentReleaseNotes, formatReleaseNotes, nextPatchVersion, resolveManifestPlatformKeys, @@ -277,6 +279,61 @@ test('release notes anchor prefers the explicit commit and falls back to the man ); }); +test('release notes anchor degrades to null when the manifest cannot be read', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error('fetch failed'); + }; + try { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('recent commit fallback marks that entries may repeat the previous release', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + for (const name of ['one', 'two']) { + writeFileSync( + path.join(directory, `apps/ai-game-creator-shell/${name}.rs`), + `fn ${name}() {}\n`, + ); + git('add', '.'); + git('commit', '--quiet', '-m', `客户端:${name}`); + } + writeFileSync(path.join(directory, 'README.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:说明'); + + const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 }); + assert.deepEqual( + recent.map((entry) => entry.subject), + ['客户端:two', '客户端:one'], + ); + const notes = formatRecentReleaseNotes(recent); + assert.match( + notes, + /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u, + ); + assert.equal(formatRecentReleaseNotes([]), ''); + assert.equal(formatRecentReleaseNotes(null), ''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 3c7201a22..97c7474ea 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -92,7 +92,7 @@ - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 -- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。 +- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 From c329c438d9bf34bc90cc2f04ca0df25c6721c754 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 20:17:26 +0800 Subject: [PATCH 43/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83PR=E7=9A=84CI=E5=9B=9E=E5=BD=92=EF=BC=88#410?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复活动回合空快照引用及停用后晚到请求覆盖问题 对齐共享卡片角标与窗口发布次数回归断言 同步原生HTTP权限检查与图集显式切片测试契约 整理菜单组件导入顺序并记录本地验证范围 --- .../src-tauri/src/tests/project.rs | 21 ++++++- .../src-tauri/src/tests/provider.rs | 10 +++- .../agent-runtime/directActiveTurns.ts | 27 ++++++--- .../src/view/project-development/index.tsx | 2 +- .../tests/directActiveTurns.test.tsx | 60 ++++++++++++++++++- .../tests/workspaceWindowSync.test.tsx | 8 +-- .../【实施计划】AGC资源菜单收纳-2026-09-17.md | 6 ++ docs/project-memory/shared-memory/pitfalls.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + .../components/CanvasCardCornerActions.tsx | 1 + .../src/components/OverflowActions.test.tsx | 3 +- .../shared/src/components/OverflowActions.tsx | 2 +- scripts/check-native-shells.mjs | 1 - ...CanvasEditorGenerationIntegration.test.tsx | 4 +- .../ImageCanvasEditorView.test.tsx | 4 +- 15 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..b2f1bdac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, + "sliceCount": null } } ], @@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dd4946ea4..7bc72b7af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); assert_eq!( @@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index a9d4c478e..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -45,7 +45,8 @@ export function useDirectActiveTurns({ * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 */ - const lastSnapshotSignatureRef = useRef(''); + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); const retryTimerRef = useRef(null); useEffect(() => { @@ -60,13 +61,16 @@ export function useDirectActiveTurns({ }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -77,7 +81,7 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } const nextTurns = Array.isArray(turns) ? turns : []; @@ -90,6 +94,7 @@ export function useDirectActiveTurns({ inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -97,22 +102,23 @@ export function useDirectActiveTurns({ resolve(); }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - lastSnapshotSignatureRef.current = ''; + lastSnapshotSignatureRef.current = '[]'; // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); @@ -123,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index cd0e4276a..1675dabb4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -14,8 +14,8 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; -import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Crosshair, diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 521cf634a..f7acdba4e 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => { { initialProps: { enabled: true } }, ); - await waitFor(() => { - expect(result.current.activeTurns).toEqual([]); - }); const emptySnapshot = result.current.activeTurns; + await act(async () => { + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); expect(result.current.activeTurns).toBe(emptySnapshot); }); @@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => { clearTimeoutSpy.mockRestore(); } }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); describe('ActiveProjectRunsPanel', () => { diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 457f61417..897a71ac9 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 - expect(publications).toHaveLength(2); + // 无原生 invoke 时空快照引用不变,只发布一次。 + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( 1, @@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 const latestOpen = vi.fn(async () => undefined); homeProjectOverride.openProject = latestOpen; rendered.rerender(view('更改显示名')); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); act(() => openProject('/tmp/window-latest-project')); expect(latestOpen).toHaveBeenCalledWith( '/tmp/window-latest-project', 'open', ); - expect(publications).toHaveLength(2); + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); rendered.unmount(); expect(cleanups).toBe(1); diff --git a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md index 96333cf43..6a8060348 100644 --- a/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】AGC资源菜单收纳-2026-09-17.md @@ -2,6 +2,12 @@ 对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。 +## PR #410 CI 修复 + +以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。 + +本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。 + 1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。 2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。 3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..633245b1a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡 工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。 +活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。 + ## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」 - **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 177465f2b..b1ee2415e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,7 @@ ## 资源画布交互与工作台状态同步 +- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 - 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。 diff --git a/packages/shared/src/components/CanvasCardCornerActions.tsx b/packages/shared/src/components/CanvasCardCornerActions.tsx index 4199f474c..9996e8923 100644 --- a/packages/shared/src/components/CanvasCardCornerActions.tsx +++ b/packages/shared/src/components/CanvasCardCornerActions.tsx @@ -1,5 +1,6 @@ import { Info } from 'lucide-react'; import type { CSSProperties, Ref } from 'react'; + import { PlatformIconButton } from './PlatformIconButton'; /** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */ diff --git a/packages/shared/src/components/OverflowActions.test.tsx b/packages/shared/src/components/OverflowActions.test.tsx index 5f25b8d3a..85ad1d46a 100644 --- a/packages/shared/src/components/OverflowActions.test.tsx +++ b/packages/shared/src/components/OverflowActions.test.tsx @@ -7,8 +7,9 @@ import { within, } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OverflowActions } from './OverflowActions'; + import { CanvasCardCornerActions } from './CanvasCardCornerActions'; +import { OverflowActions } from './OverflowActions'; afterEach(cleanup); describe('操作收纳与卡片角标', () => { diff --git a/packages/shared/src/components/OverflowActions.tsx b/packages/shared/src/components/OverflowActions.tsx index d79421308..23c4d222a 100644 --- a/packages/shared/src/components/OverflowActions.tsx +++ b/packages/shared/src/components/OverflowActions.tsx @@ -3,12 +3,12 @@ import { cloneElement, Fragment, isValidElement, + type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, - type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 6748f9628..7b5447faf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() { JSON.stringify([ { url: 'https://dev.genarrative.world/api/*' }, { url: 'https://www.genarrative.world/api/*' }, - { url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' }, { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cdbef119d..1c3c42bf5 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => { if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } - expect(metadataCornerButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(metadataCornerButton.className).toContain('shared-canvas-card-info'); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 32d777569..92b947c88 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => { const infoButton = screen.getByRole('button', { name: '查看拼图素材图片信息', }); - expect(infoButton.className).toContain( - 'image-canvas-editor__metadata-corner', - ); + expect(infoButton.className).toContain('shared-canvas-card-info'); fireEvent.click(infoButton); const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); From d5743cd4b871f0107e3a9dd6e163d469dea4d65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Thu, 17 Sep 2026 20:28:39 +0800 Subject: [PATCH 44/68] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BC=95=E6=93=8E=E8=B5=84=E6=BA=90=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=8E=E6=A8=A1=E5=9E=8B=E4=BA=A4=E4=BA=92=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 目的 资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。 ## 主要改动 - 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现 - 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段 - 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出 - 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器) - 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角 - 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份 - 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路 - 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移 - 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档 ## 验证 - `cargo check`、`cargo fmt --check` - Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例 - 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit` - `npm run check:encoding`、`git diff --check`、`npm run check:doc-index` - 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致 ## 未验证 - 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413 --- apps/ai-game-creator-shell/package.json | 2 + .../src-tauri/Cargo.toml | 6 +- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 2 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 2 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 95 +++- .../src-tauri/src/agent/direct_tools_mcp.rs | 8 +- .../src/agent/generation/prompt_context.rs | 36 +- .../src-tauri/src/commands.rs | 341 ++++++++++++- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/filesystem.rs | 30 ++ .../src-tauri/src/resource_inspect.rs | 423 +++++++++++++++- .../src-tauri/src/tests/project.rs | 59 +++ .../resource-canvas/resourceCanvasChrome.css | 88 ++++ apps/ai-game-creator-shell/src/styles.css | 106 +++- .../ResourceModelPreview.tsx | 149 ++++++ .../ResourceModelPreviewDialog.tsx | 114 +++++ .../ResourceModelViewer.tsx | 192 ++++++++ .../ResourcePreviewMedia.tsx | 57 +++ .../src/view/project-development/index.tsx | 154 ++++++ .../resourceCardPreviewModel.ts | 157 +++++- .../project-development/resourceModelScene.ts | 118 +++++ .../resourceModelThumbnail.ts | 164 +++++++ .../resourceProjectionModel.ts | 89 +++- .../useProjectResourceCardPreviews.ts | 35 +- .../appSurface/project-development.suite.ts | 23 +- .../resourceCocosPreviewContract.test.tsx | 461 ++++++++++++++++++ ...程碑】资源画布支持引擎资源预览-2026-09-17.md | 57 +++ .../shared-memory/decision-log.md | 15 + docs/project-memory/shared-memory/pitfalls.md | 10 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- package-lock.json | 111 +++++ 33 files changed, 3060 insertions(+), 53 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx create mode 100644 docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 442b1342e..3d56329d7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -61,6 +61,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -72,6 +73,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", "@types/react-window": "^1.8.8", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index ba259ccf3..f5cc8b673 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -33,7 +33,11 @@ chromiumoxide = "0.9.1" futures = "0.3" getrandom = "0.3" http = "1" -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的 +# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。 +# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让 +# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。 +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] } jsonschema = { version = "0.49.3", default-features = false } oxc_allocator = "0.143.0" oxc_ast = "0.143.0" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 84fb51e53..f25ab993d 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result ## Workflow 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. -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. +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, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) 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. 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. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 63ddd9a82..9129efc1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -8,7 +8,7 @@ The client projects three distinct facts: Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. -`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. +`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 385454c47..6debfefb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.19", + "version": "2026-08-26.20", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" + "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 945b16c1e..706015112 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { - system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); } else { system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 9e569eaea..f31a3cdd4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1380,6 +1380,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => ("code", Some("text/plain")), + /* + * Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。 + * + * 这里只做**发现分类**:`model` / `binary` 是发现层新词,与 manifest 资产 `kind` + * 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。 + * 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层 + * 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的 + * 第三份投影,同样要跟改。 + */ + "glb" => ("model", Some("model/gltf-binary")), + "gltf" => ("model", Some("model/gltf+json")), + "fbx" => ("model", Some("application/octet-stream")), + "mesh" | "skeleton" => ("model", Some("application/json")), + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => { + ("document", Some("application/json")) + } + "tmx" | "plist" => ("document", Some("application/xml")), + "effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")), + "tga" => ("image", Some("image/x-tga")), + "tif" | "tiff" => ("image", Some("image/tiff")), + "hdr" => ("image", Some("image/vnd.radiance")), + "exr" => ("image", Some("image/x-exr")), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + ("binary", Some("application/octet-stream")) + } + "pcm" => ("audio", Some("audio/pcm")), _ => ("other", None), } } @@ -1421,8 +1448,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .map(|value| value.to_lowercase()); let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? .unwrap_or_else(|| "all".to_string()); - if !["all", "image", "font", "audio", "video", "document", "code"] - .contains(&requested_kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&requested_kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } @@ -3181,7 +3210,9 @@ mod tests { "supported raster image should be importable: {path}" ); } - for path in ["assets/theme.bin", "assets/unknown.xyz"] { + // `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler), + // 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。 + for path in ["assets/theme.dat", "assets/unknown.xyz"] { assert!( !bridge_project_file_is_asset_importable(path), "unsupported project file must not be advertised as importable: {path}" @@ -3201,6 +3232,64 @@ mod tests { } } + /// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType` + /// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。 + /// + /// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。 + #[test] + fn bridge_project_file_class_covers_cocos_creator_assets() { + for (path, expected_class, expected_media_type) in [ + ("assets/model/hero.glb", "model", "model/gltf-binary"), + ("assets/model/hero.gltf", "model", "model/gltf+json"), + ("assets/model/hero.fbx", "model", "application/octet-stream"), + ("assets/model/hero.mesh", "model", "application/json"), + ("assets/model/hero.skeleton", "model", "application/json"), + ("assets/scene/main.scene", "document", "application/json"), + ("assets/scene/enemy.prefab", "document", "application/json"), + ("assets/anim/walk.anim", "document", "application/json"), + ( + "assets/anim/graph.animgraph", + "document", + "application/json", + ), + ("assets/mtl/hero.mtl", "document", "application/json"), + ("assets/shader/glow.effect", "document", "text/plain"), + ("assets/atlas/hero.plist", "document", "application/xml"), + ("assets/map/level.tmx", "document", "application/xml"), + ("assets/font/bitmap.fnt", "document", "text/plain"), + ("assets/atlas/auto.pac", "document", "application/json"), + ("assets/tex/grass.tga", "image", "image/x-tga"), + ("assets/tex/height.hdr", "image", "image/vnd.radiance"), + ( + "assets/tex/hero.texture", + "binary", + "application/octet-stream", + ), + ( + "assets/spine/hero.skel", + "binary", + "application/octet-stream", + ), + ("assets/audio/voice.pcm", "audio", "audio/pcm"), + ] { + let (class, media_type) = bridge_project_file_class(path); + assert_eq!(class, expected_class, "{path}"); + assert_eq!(media_type, Some(expected_media_type), "{path}"); + assert!( + bridge_project_file_is_asset_importable(path), + "Cocos 资源必须可登记:{path}" + ); + } + // 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。 + for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] { + assert_eq!(bridge_project_file_class(path).0, "other", "{path}"); + assert!( + !bridge_project_file_is_asset_importable(path), + "非资源文件不得可登记:{path}" + ); + } + } + #[test] fn bridge_project_file_listing_projects_importability_per_file() { let temporary = tempfile::tempdir().expect("create project file listing root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index f30331490..e9d9415df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }, "kind": { "type": "string", - "enum": ["all", "image", "font", "audio", "video", "document", "code"] + "enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"], + "description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源" }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } @@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> } if arguments.get("kind").is_some() { let kind = bounded_tool_string(arguments, "kind", 16)?; - if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9d1f102c9..dc09f659e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result Option<&'static str> { "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), + /* + * Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与 + * `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名, + * Agent 的提示词就会把「其实可以登记」的资源说成只能发现。 + */ + "glb" => Some("model/gltf-binary"), + "gltf" => Some("model/gltf+json"), + "fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + Some("application/octet-stream") + } + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh" + | "skeleton" => Some("application/json"), + "tmx" | "plist" => Some("application/xml"), + "effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"), + "tga" => Some("image/x-tga"), + "tif" | "tiff" => Some("image/tiff"), + "hdr" => Some("image/vnd.radiance"), + "exr" => Some("image/x-exr"), + "pcm" => Some("audio/pcm"), _ => None, } } @@ -246,10 +267,21 @@ mod tests { ("assets/data.json", "application/json"), ("game/index.html", "text/html"), ("game/main.rs", "text/plain"), + // 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名, + // Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。 + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.fbx", "application/octet-stream"), + ("assets/anim/walk.anim", "application/json"), + ("assets/scene/main.scene", "application/json"), + ("assets/shader/glow.effect", "text/plain"), + ("assets/atlas/hero.plist", "application/xml"), + ("assets/tex/grass.tga", "image/x-tga"), + ("assets/audio/voice.pcm", "audio/pcm"), ] { assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); } - assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); + // `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。 + assert_eq!(prompt_context_media_type("assets/unknown.dat"), None); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 739dc5a3f..a2e86d06e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3092,7 +3092,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3185,7 +3185,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3202,7 +3202,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_hidden_and_build_tree_sources() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3232,20 +3232,147 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_unknown_and_invalid_text_files() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); fs::create_dir_all(root.join("assets")).expect("create assets directory"); - fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file"); + fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file"); fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source"); assert!( - import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()]) + import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()]) .is_err() ); assert!( import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err() ); + // `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类: + // 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。 + fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset"); + let imported = + import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()]) + .expect("import engine buffer asset"); + assert_eq!(imported.assets.len(), 1); + assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document")); + } + + /// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest, + /// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。 + /// + /// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。 + #[test] + fn local_project_asset_import_registers_cocos_creator_assets() { + // 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录 + // 在 Windows owner 校验下直接失败。 + let project = crate::tests::canonical_test_tempdir("cocos-asset-import-"); + let root = project.path(); + init_local_game_project_at(root, "cocos-import", "Cocos import") + .expect("initialize project"); + for directory in [ + "model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio", + ] { + fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir"); + } + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + for (path, bytes) in [ + ("assets/model/hero.glb", glb), + ("assets/model/hero.fbx", fbx), + ( + "assets/anim/walk.anim", + b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(), + ), + ( + "assets/anim/graph.animgraph", + b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(), + ), + ( + "assets/scene/main.scene", + b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(), + ), + ( + "assets/scene/enemy.prefab", + b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(), + ), + ( + "assets/mtl/hero.mtl", + b"{\"__type__\":\"cc.Material\"}".to_vec(), + ), + ("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()), + ( + "assets/atlas/hero.plist", + b"".to_vec(), + ), + ( + "assets/map/level.tmx", + b"".to_vec(), + ), + ("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]), + ("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]), + ] { + fs::write(root.join(path), bytes).expect("write cocos asset"); + } + + let relative_paths = [ + "assets/model/hero.glb", + "assets/model/hero.fbx", + "assets/anim/walk.anim", + "assets/anim/graph.animgraph", + "assets/scene/main.scene", + "assets/scene/enemy.prefab", + "assets/mtl/hero.mtl", + "assets/shader/glow.effect", + "assets/atlas/hero.plist", + "assets/map/level.tmx", + "assets/tex/hero.texture", + "assets/audio/voice.pcm", + ] + .map(str::to_string) + .to_vec(); + let imported = + import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos"); + let kinds = imported + .assets + .iter() + .map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref())) + .collect::>(); + assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/anim/walk.anim"), + Some(&Some("character-animation")) + ); + assert_eq!( + kinds.get("assets/anim/graph.animgraph"), + Some(&Some("character-animation")) + ); + assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code"))); + assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code"))); + assert_eq!( + kinds.get("assets/atlas/hero.plist"), + Some(&Some("document")) + ); + // 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。 + assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/tex/hero.texture"), + Some(&Some("document")) + ); + assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio"))); + + // 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8, + // 否则「结构预览」拿到的是一堆乱码。 + fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe]) + .expect("write invalid prefab"); + assert!(import_local_project_assets_for_agent( + root, + &["assets/scene/broken.prefab".to_string()] + ) + .is_err()); } /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, @@ -3962,6 +4089,140 @@ fn agent_local_project_file_type( }, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), + /* + * Cocos Creator(3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与 + * 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道** + * 能支撑的 `media_type`。 + * + * - `document`:Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览; + * - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空; + * - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。 + * + * 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现 + * 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。 + */ + "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tmx" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { + Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "character-animation", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "effect" | "chunk" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "plist" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "labelatlas" | "pac" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fnt" | "atlas" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "glb" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf-binary", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "gltf" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf+json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fbx" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mesh" | "skeleton" => Some(AgentLocalProjectFileType { + // Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是 + // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 + // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 + category: "binary", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { + Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "document", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "psd" | "znt" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "image", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tga" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-tga", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tif" | "tiff" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/tiff", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "hdr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/vnd.radiance", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "exr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-exr", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "pcm" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/pcm", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), _ => None, }; let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?; @@ -5055,6 +5316,65 @@ pub(crate) fn read_local_project_text_preview_at( Ok(preview) } +/** + * 读取引擎(Cocos)序列化资源的**只读结构预览**。 + * + * 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于 + * 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变 + * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 + * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 + */ +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + #[tauri::command] pub(crate) async fn read_local_project_media_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, @@ -5094,7 +5414,8 @@ pub(crate) fn read_local_project_media_preview_at( let kind = match category.trim() { "art" => ProjectMediaPreviewKind::Art, "audio" => ProjectMediaPreviewKind::Audio, - _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + "model" => ProjectMediaPreviewKind::Model, + _ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()), }; let is_registered_media = manifest.assets.iter().any(|asset| { asset.local_path == normalized_path @@ -5105,6 +5426,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&asset.local_path, &asset.media_type) } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&asset.local_path, &asset.media_type) + } } }) || manifest.tasks.iter().any(|task| { task.status == GameCreationAppTaskStatus::Completed @@ -5116,6 +5440,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&normalized_path, "") } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&normalized_path, "") + } } }); if !is_registered_media { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 60d3113a3..68ba228eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2630,6 +2630,7 @@ fn main() { read_local_project_image_preview, save_local_project_asset_file, read_local_project_text_preview, + read_local_project_structured_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, write_local_project_file, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 6f067628b..1e634bbda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,21 @@ use super::*; +/** + * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 + * + * 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源 + * 目录里的普通文件夹,不属于这里。 + */ +fn is_engine_generated_root_directory(relative_path: &str) -> bool { + if relative_path.contains('/') { + return false; + } + matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "profiles" | "local" + ) +} + pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at( }); } + /* + * 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。 + * + * 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在 + * 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的 + * `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让 + * 前端资源树加载一堆永远用不上的条目。 + */ + let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if skip_engine_generated_directories + && is_engine_generated_root_directory(&relative_path) + { + continue; + } files.push(LocalProjectFileEntry { path: relative_path, kind: "directory".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index d7ca4b4ba..3a666b253 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -13,6 +13,18 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +/** + * 模型预览的字节上限。 + * + * 模型要整份送进渲染器才能出预览,而预览载荷是 base64 data URL(约放大 1/3): + * 与通用媒体上限(`PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES`)取同一个 32 MiB, + * 覆盖绝大多数 `.glb` / `.fbx`,同时把单条 IPC 载荷压在约 43 MiB 以内。 + * + * 超过上限的模型不报错、也不半渲染:卡面直接降级成类型卡(见前端 + * `projectResourceCardPreviewKind` 的 `model` 分支)。真要再往上放宽,得先把预览载荷 + * 从 base64 JSON 换成 Tauri 的原始字节通道,否则 JS 侧的字符串副本会先炸掉内存。 + */ +const PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; @@ -46,6 +58,68 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) enum ProjectMediaPreviewKind { Art, Audio, + /// 三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给前端渲染器出预览。 + Model, +} + +/** + * 引擎资源结构预览的读取结果。 + * + * `content` 为 `None` 表示该资源不是 UTF-8 文本(例如二进制的 `.pac` 变体)—— + * 这是**正常降级**而不是错误:卡面据此画类型卡,不再向用户报「预览失败」。 + */ +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectStructuredPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, +} + +/** + * 引擎(Cocos 等)序列化资源的结构预览准入。 + * + * 与 `is_supported_project_text_resource` **刻意分开**:那份白名单同时服务 UI 编辑器 + * 的「可编辑文本资源」判定,把 `.prefab` / `.scene` / `.anim` 塞进去会让它们在这条 + * 编辑链路里被当成普通文本资产;这里只服务资源画布的只读结构预览。 + */ +pub(crate) fn is_supported_project_structured_resource(path: &str, media_type: &str) -> bool { + if !matches!( + path_extension(path).as_deref(), + Some( + "scene" + | "fire" + | "prefab" + | "anim" + | "animation" + | "animgraph" + | "animgraphvari" + | "animask" + | "mtl" + | "material" + | "pmtl" + | "effect" + | "chunk" + | "tmx" + | "plist" + | "labelatlas" + | "atlas" + | "fnt" + | "pac" + | "mesh" + | "skeleton" + ) + ) { + return false; + } + let media_type = media_type.trim().to_ascii_lowercase(); + !(media_type.starts_with("audio/") + || media_type.starts_with("video/") + || media_type.starts_with("image/") + || media_type.starts_with("model/") + || media_type.starts_with("font/")) } pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { @@ -116,11 +190,33 @@ pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &s let media_type = media_type.trim().to_ascii_lowercase(); matches!( path_extension(path).as_deref(), - Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + Some( + "gif" + | "svg" + | "avif" + | "bmp" + | "mp4" + | "webm" + | "mov" + // 引擎图像容器:浏览器解不了,先由原生侧转码成 PNG 再走同一条预览链路。 + | "tga" + | "tif" + | "tiff" + | "hdr" + ) ) || media_type.starts_with("video/") || media_type == "image/svg+xml" } +/// 模型预览准入:引擎三维模型与网格数据(Cocos 的 `.glb` / `.gltf` / `.fbx`)。 +pub(crate) fn is_supported_project_model_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("glb" | "gltf" | "fbx") + ) || media_type.starts_with("model/") +} + pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { let media_type = media_type.trim().to_ascii_lowercase(); matches!( @@ -184,6 +280,56 @@ pub(crate) fn load_local_project_media_preview( ) } +/** + * 读取引擎序列化资源的结构预览。 + * + * `media_type` 由调用方从 manifest 登记项透传(任务产物没有登记项,传空串): + * 这条读取**不自己维护第五份扩展名表**,准入判定与登记口径共用同一个函数。 + */ +pub(crate) fn load_local_project_structured_preview_with_cancellation( + root: &Path, + relative_path: &str, + media_type: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + if !is_supported_project_structured_resource(&normalized, media_type) { + return Err("结构预览只支持引擎序列化资源".to_string()); + } + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "引擎资源", + cancellation, + )?; + cancellation.check()?; + let byte_len = bytes.len() as u64; + let media_type = project_structured_media_type(&normalized); + Ok(LocalProjectStructuredPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len, + // 非 UTF-8 的二进制变体不是错误:返回 `None`,由卡面降级成类型卡。 + content: String::from_utf8(bytes).ok(), + }) +} + +fn project_structured_media_type(path: &str) -> &'static str { + match path_extension(path).as_deref() { + Some("effect" | "chunk" | "fnt" | "atlas") => "text/plain", + Some("tmx" | "plist") => "application/xml", + Some( + "scene" | "fire" | "prefab" | "terrain" | "anim" | "animation" | "animgraph" + | "animgraphvari" | "animask" | "mtl" | "material" | "pmtl" | "labelatlas" | "pac" + | "mesh" | "skeleton", + ) => "application/json", + _ => "application/octet-stream", + } +} + pub(crate) fn load_local_project_media_preview_with_cancellation( root: &Path, relative_path: &str, @@ -193,29 +339,107 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; - let bytes = read_stable_project_resource( - root, - &normalized, - PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, - "项目媒体资源", - cancellation, - )?; + let (max_bytes, label) = match kind { + ProjectMediaPreviewKind::Model => (PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES, "项目模型资源"), + ProjectMediaPreviewKind::Art | ProjectMediaPreviewKind::Audio => { + (PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, "项目媒体资源") + } + }; + let bytes = read_stable_project_resource(root, &normalized, max_bytes, label, cancellation)?; if bytes.is_empty() { return Err("媒体文件为空,无法预览".to_string()); } cancellation.check()?; - let media_type = detect_project_media_type(&normalized, &bytes, kind)?; - cancellation.check()?; - let dimensions = (kind == ProjectMediaPreviewKind::Art) - .then(|| detect_project_art_dimensions(&bytes, media_type)) - .flatten(); + let source_byte_len = bytes.len() as u64; + /** + * 图像容器(TGA / TIFF / HDR / EXR)在浏览器里没有解码器:先在原生侧转成 PNG, + * 再走与 GIF / BMP / AVIF 完全相同的「数据 URL + 图片卡」链路。转码是**只读**的, + * 不改工程文件;像素尺寸仍受既有的尺寸与像素总量上限约束,不会因为多一层解码 + * 就放宽任何一条既有边界。 + */ + let transcoded = (kind == ProjectMediaPreviewKind::Art) + .then(|| transcode_project_art_container(&normalized, &bytes)) + .flatten() + .transpose()?; + let (media_type, dimensions, payload) = match transcoded { + Some(transcoded) => ( + transcoded.media_type, + Some((transcoded.pixel_width, transcoded.pixel_height)), + transcoded.bytes, + ), + None => { + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); + (media_type, dimensions, bytes) + } + }; Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), - byte_len: bytes.len() as u64, + // `byteLen` 始终是**源文件**的大小:转码只影响预览载荷,不改变「这是多大的资源」。 + byte_len: source_byte_len, pixel_width: dimensions.map(|(width, _)| width), pixel_height: dimensions.map(|(_, height)| height), - data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, + data_url: encode_project_resource_preview_data_url(media_type, &payload, cancellation)?, + }) +} + +struct TranscodedProjectArtContainer { + bytes: Vec, + media_type: &'static str, + pixel_width: u32, + pixel_height: u32, +} + +fn transcode_project_art_container( + relative_path: &str, + bytes: &[u8], +) -> Option> { + let format = match path_extension(relative_path).as_deref()? { + "tga" => image::ImageFormat::Tga, + "tif" | "tiff" => image::ImageFormat::Tiff, + "hdr" => image::ImageFormat::Hdr, + _ => return None, + }; + Some(transcode_project_art_container_with_format( + relative_path, + bytes, + format, + )) +} + +fn transcode_project_art_container_with_format( + relative_path: &str, + bytes: &[u8], + format: image::ImageFormat, +) -> Result { + let decoded = image::load_from_memory_with_format(bytes, format) + .map_err(|error| format!("图像容器解码失败,无法在客户端预览:{relative_path}: {error}"))? + // HDR / EXR 是浮点像素格式,PNG 只能装整数像素:统一降到 8 位 RGBA, + // 与其它预览图(含 alpha 版)保持同一种像素口径。 + .to_rgba8(); + let (pixel_width, pixel_height) = decoded.dimensions(); + let pixels = u64::from(pixel_width) * u64::from(pixel_height); + if pixel_width == 0 + || pixel_height == 0 + || pixel_width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixel_height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return Err("图像尺寸过大,无法在客户端预览".to_string()); + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(decoded) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|error| format!("图像容器转码失败:{relative_path}: {error}"))?; + Ok(TranscodedProjectArtContainer { + bytes: png, + media_type: "image/png", + pixel_width, + pixel_height, }) } @@ -430,6 +654,23 @@ fn detect_project_media_type( bytes: &[u8], kind: ProjectMediaPreviewKind, ) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Model { + // 只按文件签名判定,不看扩展名:登记的是 `.glb` 却塞了别的内容时宁可报错, + // 也不要让渲染器去猜。 + if bytes.starts_with(b"glTF") { + return Ok("model/gltf-binary"); + } + if std::str::from_utf8(bytes).is_ok_and(|text| { + let trimmed = text.trim_start(); + trimmed.starts_with('{') && trimmed.contains("\"asset\"") + }) { + return Ok("model/gltf+json"); + } + if bytes.starts_with(b"Kaydara FBX Binary") || bytes.starts_with(b"; FBX") { + return Ok("application/octet-stream"); + } + return Err("模型预览只支持 GLB、glTF 或 FBX 文件".to_string()); + } if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { validate_safe_svg(bytes)?; return Ok("image/svg+xml"); @@ -623,7 +864,7 @@ mod tests { #[test] fn text_preview_requires_utf8_and_a_supported_extension() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); @@ -645,7 +886,7 @@ mod tests { #[test] fn media_preview_accepts_safe_svg_and_rejects_active_svg() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); fs::write( root.path().join("assets/icon.svg"), @@ -689,7 +930,7 @@ mod tests { #[test] fn media_preview_reports_safe_dimensions_for_extended_art_images() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); let mut gif = b"GIF89a".to_vec(); @@ -735,12 +976,156 @@ mod tests { } } + /// 引擎图像容器(Cocos 的 `.tga` / `.tif` / `.hdr`)在浏览器里没有解码器: + /// 原生侧必须把它转成 PNG 再交给前端,并把真实像素尺寸一并带出去。 + #[test] + fn art_container_preview_transcodes_tga_to_png() { + // 用工程自带的临时目录助手:它会 canonicalize 并把目录 owner 归到当前用户, + // 否则 `%TEMP%` 下的临时目录在 Windows 上会被 owner 校验直接拒绝。 + let root = crate::tests::canonical_test_tempdir("engine-art-container-"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut tga = vec![0_u8; 18]; + tga[2] = 2; // 未压缩真彩色 + tga[12..14].copy_from_slice(&2_u16.to_le_bytes()); // 宽 2 + tga[14..16].copy_from_slice(&2_u16.to_le_bytes()); // 高 2 + tga[16] = 24; // 24 位像素 + tga[17] = 0x20; // 左上角原点 + tga.extend_from_slice(&[ + 0, 0, 255, // BGR:红 + 0, 255, 0, // 绿 + 255, 0, 0, // 蓝 + 255, 255, 255, // 白 + ]); + fs::write(root.path().join("assets/grass.tga"), tga).expect("tga"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/grass.tga", + ProjectMediaPreviewKind::Art, + ) + .expect("transcoded tga preview"); + assert_eq!(preview.media_type, "image/png"); + assert_eq!(preview.pixel_width, Some(2)); + assert_eq!(preview.pixel_height, Some(2)); + assert!(preview.data_url.starts_with("data:image/png;base64,")); + } + + /// 引擎序列化资源:UTF-8 的给正文(前端再抽结构摘要),非 UTF-8 的**降级**成 + /// `content: null`(卡面画类型卡),不是报错;未登记进白名单的扩展名一律拒绝。 + #[test] + fn structured_preview_reads_cocos_serialized_assets_and_degrades_binary() { + let root = crate::tests::canonical_test_tempdir("engine-structured-preview-"); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + fs::create_dir_all(root.path().join("assets/anim")).expect("anim dir"); + fs::write( + root.path().join("assets/anim/walk.anim"), + "[{\"__type__\":\"cc.AnimationClip\",\"_duration\":1.25}]", + ) + .expect("animation clip"); + fs::write( + root.path().join("assets/anim/broken.pac"), + [0xff, 0xfe, 0x00], + ) + .expect("binary pac variant"); + fs::write( + root.path().join("assets/anim/glow.effect"), + "CCEffect %{\n techniques: []\n}", + ) + .expect("effect"); + + let clip = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "application/json", + &cancellation, + ) + .expect("animation clip preview"); + assert_eq!(clip.media_type, "application/json"); + assert!(clip + .content + .as_deref() + .is_some_and(|content| content.contains("cc.AnimationClip"))); + + let effect = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/glow.effect", + "text/plain", + &cancellation, + ) + .expect("effect preview"); + assert_eq!(effect.media_type, "text/plain"); + assert!(effect.content.is_some()); + + let degraded = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/broken.pac", + "application/json", + &cancellation, + ) + .expect("binary variant degrades instead of failing"); + assert_eq!(degraded.content, None); + assert_eq!(degraded.byte_len, 3); + + assert!(load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "audio/mpeg", + &cancellation, + ) + .is_err()); + } + + /// 模型预览按**文件签名**判定媒体类型:GLB / glTF / FBX 各走各的,别的内容一律拒绝。 + #[test] + fn media_preview_detects_engine_model_media_types() { + let root = crate::tests::canonical_test_tempdir("engine-model-media-"); + fs::create_dir_all(root.path().join("assets/model")).expect("model dir"); + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); + fs::write(root.path().join("assets/model/hero.glb"), glb).expect("glb"); + fs::write( + root.path().join("assets/model/hero.gltf"), + "{\"asset\":{\"version\":\"2.0\"},\"meshes\":[]}", + ) + .expect("gltf"); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + fs::write(root.path().join("assets/model/hero.fbx"), fbx).expect("fbx"); + fs::write(root.path().join("assets/model/broken.glb"), b"not-a-model") + .expect("broken model"); + + for (path, expected) in [ + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.gltf", "model/gltf+json"), + ("assets/model/hero.fbx", "application/octet-stream"), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Model) + .expect("model preview"); + assert_eq!(preview.media_type, expected, "{path}"); + assert!( + preview + .data_url + .starts_with(&format!("data:{expected};base64,")), + "{path}" + ); + } + assert!(load_local_project_media_preview( + root.path(), + "assets/model/broken.glb", + ProjectMediaPreviewKind::Model, + ) + .is_err()); + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { use std::os::unix::fs::symlink; - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); let outside = tempfile::tempdir().expect("outside"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); let source = outside.path().join("source.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 5d746c7cc..3fa66b97f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2989,6 +2989,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { fs::remove_dir_all(root).ok(); } +/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效: +/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。 +#[test] +fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() { + let cocos = canonical_test_tempdir("engine-generated-dirs-"); + let cocos = cocos.path(); + fs::create_dir_all(cocos.join("assets")).expect("create assets"); + fs::create_dir_all(cocos.join("library/imported")).expect("create library"); + fs::create_dir_all(cocos.join("temp/programming")).expect("create temp"); + fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles"); + fs::create_dir_all(cocos.join("local")).expect("create local"); + fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model"); + fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file"); + fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file"); + fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file"); + fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file"); + fs::write( + cocos.join("package.json"), + r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#, + ) + .expect("write cocos package.json"); + + let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + let paths = listed + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + assert!(paths.contains(&"assets/hero.glb"), "{paths:?}"); + for skipped in [ + "library", + "library/imported/hero.json", + "temp", + "temp/programming/packer.cpp", + "profiles", + "profiles/v2/user.json", + "local", + "local/settings.json", + ] { + assert!( + !paths.contains(&skipped), + "引擎生成目录必须被过滤:{skipped} / {paths:?}" + ); + } + + let plain = canonical_test_tempdir("plain-project-dirs-"); + let plain = plain.path(); + fs::create_dir_all(plain.join("library")).expect("create plain library"); + fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file"); + let listed = list_local_project_files_at(plain).expect("list plain project files"); + assert!( + listed + .files + .iter() + .any(|file| file.path == "library/index.ts"), + "非引擎工程的同名目录不得被过滤" + ); +} + #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 40de7843a..a7f485eaf 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -942,6 +942,94 @@ overscroll-behavior: contain; } +/* + * 引擎模型放大预览:与文档预览同一套浮层口径(宽度 / 圆角 / 头尾结构), + * 只有主体换成交互式三维视口 —— 视口自己吃掉指针与滚轮事件。 + */ +.game-resource-model-preview { + display: flex; + flex-direction: column; + width: min(1040px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + min-width: 0; + border: 1px solid var(--platform-surface-border); + border-radius: 16px; + overflow: hidden; +} + +.game-resource-model-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--platform-surface-border); +} + +.game-resource-model-preview__header strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.game-resource-model-preview__actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +.game-resource-model-preview__body { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + min-width: 0; + padding: 16px 20px 20px; +} + +.game-resource-model-preview__hint { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--platform-warm-text, #8b5b45); + font-size: 12px; +} + +.game-resource-model-viewer { + position: relative; + flex: 1 1 auto; + min-height: min(62dvh, 560px); + border: 1px solid var(--platform-surface-border); + border-radius: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + overflow: hidden; + /* 视口内的指针手势一律给三维视角,不冒泡去拖动画布。 */ + touch-action: none; +} + +.game-resource-model-viewer__canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; +} + +.game-resource-model-viewer__canvas:active { + cursor: grabbing; +} + +.game-resource-model-viewer__notice { + display: grid; + height: 100%; + margin: 0; + padding: 24px; + place-items: center; + color: #8b5b45; + font-size: 13px; + text-align: center; +} + /* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */ .game-resource-panel { width: min(880px, calc(100% - 32px)); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 613ec51e4..c5fa3bde4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7326,7 +7326,16 @@ iframe.preview-frame { min-height: var(--resource-card-height); padding: 0; overflow: visible; - border: 0; + /* + * 边框**常驻 1px 透明**,状态只改颜色。 + * + * 卡片是 `box-sizing: border-box`,且卡面(`.game-resource-card-visual`)与角标都是 + * 绝对定位、以 **padding box** 为包含块:底态若是 `border: 0`,悬停 / 选中时才出现的 + * 1px 边框会把内容盒四边各吃掉 1px,卡面与角标当场位移并缩小 2px —— 用户看到的就是 + * 「一悬浮,卡片里面的东西跟着动」。常驻透明边框让所有状态的 padding box 完全一致, + * 外尺寸仍然等于 `--resource-card-width/height`(border-box),画布坐标不受影响。 + */ + border: 1px solid transparent; border-radius: 12px; background: #fff; color: #4e382f; @@ -7348,9 +7357,8 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } -/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 - 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 - 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ +/* 基态已经是 1px 透明边框,这里只把颜色点亮;宽度与圆角保持 1px / 12px, + padding box 不变,因此卡面与角标在状态切换时不会位移。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { @@ -7686,6 +7694,96 @@ iframe.preview-frame { gap: 10px; } +/* + * 引擎资源卡(引擎三维模型 / 序列化资源 / 无法解码的容器)。 + * + * 三者的共同点:卡面要么是渲染出来的缩略图,要么是「类型 + 扩展名」的类型卡, + * 都必须与既有卡片同族 —— 因此沿用同一套浅色渐变底与居中排布,不新造一套视觉语言。 + */ +.game-resource-card-model-visual { + /* + * 绝对定位铺满卡面:`width/height: 100%` 在 `place-items: center` 的网格里会因为 + * 行高不确定而退化成"按内容定高",缩略图就以自然比例溢出卡面被裁掉(看起来像被拉伸)。 + * 用 `inset: 0` 把盒子定死,图片才能在固定框里做 letterbox。 + */ + position: absolute; + inset: 0; + display: grid; + width: 100%; + height: 100%; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + place-items: center; +} + +.game-resource-card-model-visual img { + /* + * 图片绝对定位铺满宿主:宿主是 `place-items: center` 的网格,网格项的高度会退化成 + * "按内容定高"(`height: 100%` 解析成 auto),缩略图就会按自身比例长过卡片并被裁掉。 + * 铺满 + `object-fit: contain` 才是「不拉伸、不裁切」的口径。 + */ + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.game-resource-card-engine-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 12px; + background: linear-gradient(145deg, #f7f2ec, #e6ddd2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-engine-label { + max-width: 100%; + padding: 1px 7px; + border: 1px solid rgb(139 91 69 / 28%); + border-radius: 999px; + background: rgb(255 255 255 / 72%); + color: #8b5b45; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: center; + word-break: break-word; +} + +/* 引擎序列化资源:结构摘要按"最多三行"截断,与文档卡的卡面口径一致。 */ +.game-resource-card-structured-visual { + position: absolute; + inset: 0; + display: grid; + gap: 8px; + width: 100%; + height: 100%; + padding: 14px; + background: linear-gradient(145deg, #fff8f1, #f2ded2); + color: #8b5b45; + align-content: center; + place-items: center; +} + +.game-resource-card-structured-text { + display: -webkit-box; + max-height: 100%; + overflow: hidden; + color: #674c41; + font-size: 11px; + line-height: 1.55; + text-align: left; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .game-resource-card-version-relations { color: #8e6f62; font-size: 9px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx new file mode 100644 index 000000000..189d0f8ba --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from 'react'; + +import { renderResourceModelThumbnail } from './resourceModelThumbnail'; + +type ResourceModelPreviewProps = { + /** 预览管线给出的模型字节 URL(blob URL)。 */ + sourceUrl: string; + /** 原生侧判定的媒体类型(`model/gltf-binary` / `model/gltf+json` / FBX 的 octet-stream)。 */ + mediaType: string; + /** 缩略图缓存身份(见 `resourceModelThumbnailIdentity`):blob URL 会变,身份不会。 */ + identity: string; + /** 渲染不出来时画在同一个位置上的类型卡文案(例如「模型 · GLB」)。 */ + fallbackLabel: string; +}; + +/** + * 缩略图超采样倍数。 + * + * 资源画布用 `transform: scale(--resource-section-zoom)` 放大栏目视图(真机 1.5 倍),而 + * `ResizeObserver` 与 `offsetWidth` 都只看**布局盒**:按布局尺寸 1:1 渲染出来的图,被画布 + * 放大后就是糊的。按 2 倍超采样既有余量覆盖画布缩放,也不至于让单张缩略图失控。 + */ +const RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE = 2; +const RESOURCE_MODEL_THUMBNAIL_MAX_EDGE = 1024; + +/** + * 资源卡上的引擎模型缩略图。 + * + * 这一层只关心「把渲染结果画出来」,渲染本身在 `resourceModelThumbnail` 的单例队列里 + * (整页只有一个 WebGL 上下文)。渲染失败**不算预览失败**:卡面就地降级成类型卡, + * 预览管线里已经读到的字节与状态保持不变,排障信息通过 + * `data-model-preview-status` 暴露在 DOM 上。 + */ +export function ResourceModelPreview({ + sourceUrl, + mediaType, + identity, + fallbackLabel, +}: ResourceModelPreviewProps) { + const hostRef = useRef(null); + const [thumbnail, setThumbnail] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + /** + * 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。 + * + * 卡片在总览小图与栏目大图里是同一个组件:只在挂载时量一次,会拿到当时那个更小的盒子, + * 之后卡片放大也不会重画 —— 表现为缩略图被放大到发虚、比例与卡面不一致。 + */ + const [renderSize, setRenderSize] = useState<{ + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + const measure = () => { + /* + * 取**布局尺寸**(`offsetWidth` 不受祖先 transform 影响)而不是 `getBoundingClientRect`: + * 画布缩放是 transform,像素值会随缩放变化;布局尺寸才是稳定的口径,再乘超采样倍数。 + */ + const width = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 192, + Math.round( + (host.offsetWidth || 320) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + const height = Math.min( + RESOURCE_MODEL_THUMBNAIL_MAX_EDGE, + Math.max( + 144, + Math.round( + (host.offsetHeight || 240) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE, + ), + ), + ); + setRenderSize((current) => + current && current.width === width && current.height === height + ? current + : { width, height }, + ); + }; + measure(); + // 测试用的 jsdom 没有 ResizeObserver:没有它就退化成"只在挂载时量一次"。 + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(host); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!renderSize) { + return undefined; + } + let disposed = false; + setThumbnail(null); + setStatus('loading'); + const request = { + sourceUrl, + mediaType, + identity, + width: renderSize.width, + height: renderSize.height, + }; + void renderResourceModelThumbnail(request) + .then((dataUrl) => { + if (disposed) { + return; + } + setThumbnail(dataUrl); + setStatus('ready'); + }) + .catch(() => { + if (!disposed) { + setStatus('failed'); + } + }); + return () => { + disposed = true; + }; + }, [identity, mediaType, renderSize, sourceUrl]); + + return ( +
+ {thumbnail ? ( + + ) : ( + {fallbackLabel} + )} + + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx new file mode 100644 index 000000000..503ccaed8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreviewDialog.tsx @@ -0,0 +1,114 @@ +import { Maximize2, RotateCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel'; +import { + ResourceModelViewer, + type ResourceModelViewerHandle, +} from './ResourceModelViewer'; +import type { ProjectResource } from './resourceProjectionModel'; + +/** + * 引擎模型的**放大预览浮层**:在独立浮层里给出与 3D 建模软件一致的视角操作。 + * + * 卡片上仍然是静态缩略图(一张画布上十几张模型卡共用缩略图那一个 WebGL 上下文); + * 只有打开这个浮层时才会新建一个交互式上下文,关闭即释放。浮层内的指针与滚轮事件由 + * three 的 OrbitControls 独占:左键旋转、右键/中键平移、滚轮推拉。 + */ +export function ResourceModelPreviewDialog({ + resource, + identity, + preview, + onRequestPreview, + onClose, +}: { + resource: ProjectResource; + identity: string; + preview: ProjectResourceCardPreviewState; + onRequestPreview: ( + resource: ProjectResource, + identity: string, + reason: 'detail', + ) => void; + onClose: () => void; +}) { + const [resetSignal, setResetSignal] = useState(0); + const viewerHandleRef = useRef(null); + + useEffect(() => { + onRequestPreview(resource, identity, 'detail'); + }, [resource, identity, onRequestPreview]); + + const handleReady = useCallback((handle: ResourceModelViewerHandle) => { + viewerHandleRef.current = handle; + }, []); + + const sourceUrl = + preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + + return ( + +
+ {resource.label} +
+ setResetSignal((current) => current + 1)} + > + +
+
+
+ {preview.status === 'failed' ? ( + <> +

{preview.error}

+ {preview.retryable ? ( + onRequestPreview(resource, identity, 'detail')} + > + 重试 + + ) : null} + + ) : preview.status === 'loaded' && !sourceUrl ? ( +

没有可预览的模型内容

+ ) : sourceUrl ? ( + + ) : ( +

正在加载模型…

+ )} +

+

+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx new file mode 100644 index 000000000..4b06525cc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, + type ResourceModelSource, +} from './resourceModelScene'; + +export type ResourceModelViewerHandle = { + /** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */ + resetView: () => void; +}; + +type ResourceModelViewerProps = { + source: ResourceModelSource; + /** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */ + resetSignal: number; + onReady: (handle: ResourceModelViewerHandle) => void; +}; + +/** + * 交互式模型预览画布:视角操作与 3D 建模软件一致。 + * + * - 左键拖拽:旋转(Orbit) + * - 右键 / 中键拖拽:平移(Pan) + * - 滚轮:推拉(Zoom) + * + * 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后 + * 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`: + * 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。 + */ +export function ResourceModelViewer({ + source, + resetSignal, + onReady, +}: ResourceModelViewerProps) { + const hostRef = useRef(null); + const resetRef = useRef<(() => void) | null>(null); + /** + * 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染 + * 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。 + */ + const sourceRef = useRef(source); + sourceRef.current = source; + const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( + 'loading', + ); + const [error, setError] = useState(''); + + useEffect(() => { + let disposed = false; + let cleanup: (() => void) | undefined; + setStatus('loading'); + setError(''); + const host = hostRef.current; + if (!host) { + return undefined; + } + void (async () => { + try { + const THREE = await import('three'); + const { OrbitControls } = await import( + 'three/examples/jsm/controls/OrbitControls.js' + ); + if (disposed) { + return; + } + const width = Math.max(240, Math.round(host.clientWidth)); + const height = Math.max(200, Math.round(host.clientHeight)); + const modelSource = { + sourceUrl: sourceRef.current.sourceUrl, + mediaType: sourceRef.current.mediaType, + }; + const renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(width, height, false); + renderer.domElement.className = 'game-resource-model-viewer__canvas'; + host.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + addResourceModelLights(THREE, scene); + const camera = new THREE.PerspectiveCamera( + 35, + width / height, + 0.01, + 10_000, + ); + + const controls = new OrbitControls(camera, renderer.domElement); + // 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。 + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = true; + controls.zoomSpeed = 0.9; + // 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。 + controls.minDistance = 0.01; + controls.maxDistance = Number.POSITIVE_INFINITY; + + const object = await loadResourceModelObject(modelSource); + if (disposed) { + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + return; + } + scene.add(object); + const frame = () => { + const { center } = frameResourceModelInCamera(THREE, object, camera); + // 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。 + controls.target.copy(center); + controls.update(); + }; + frame(); + controls.update(); + + const resizeObserver = new ResizeObserver(() => { + const nextWidth = Math.max(240, Math.round(host.clientWidth)); + const nextHeight = Math.max(200, Math.round(host.clientHeight)); + camera.aspect = nextWidth / nextHeight; + camera.updateProjectionMatrix(); + renderer.setSize(nextWidth, nextHeight, false); + }); + resizeObserver.observe(host); + + let frameHandle = 0; + const renderLoop = () => { + controls.update(); + renderer.render(scene, camera); + frameHandle = window.requestAnimationFrame(renderLoop); + }; + frameHandle = window.requestAnimationFrame(renderLoop); + + resetRef.current = () => { + frame(); + }; + onReady({ resetView: () => resetRef.current?.() }); + setStatus('ready'); + + cleanup = () => { + window.cancelAnimationFrame(frameHandle); + resizeObserver.disconnect(); + controls.dispose(); + scene.remove(object); + disposeResourceModelObject(object); + renderer.dispose(); + renderer.domElement.remove(); + resetRef.current = null; + }; + } catch (viewerError) { + if (!disposed) { + setStatus('failed'); + setError( + viewerError instanceof Error + ? viewerError.message + : String(viewerError), + ); + } + } + })(); + return () => { + disposed = true; + cleanup?.(); + }; + }, [onReady, source.mediaType, source.sourceUrl]); + + useEffect(() => { + if (resetSignal > 0) { + resetRef.current?.(); + } + }, [resetSignal]); + + return ( +
+ {status === 'ready' ? null : ( +

+ {status === 'loading' + ? '正在加载模型…' + : `当前环境无法渲染三维预览${error ? `:${error}` : ''}`} +

+ )} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx index 281ce517c..7001825a5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourcePreviewMedia.tsx @@ -12,8 +12,13 @@ import { type ProjectResourceCardPreviewState, projectResourceCardPreviewVariant, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import type { ProjectResource } from './resourceProjectionModel'; +import { projectResourceTypeLabel } from './resourceProjectionModel'; type ResourcePreviewMediaProps = { /** @@ -96,7 +101,59 @@ export function ResourcePreviewMedia({ : null; const sourceUrl = preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null; + const engineBadgeLabel = (() => { + if (!resource) { + return '引擎资源'; + } + const extension = projectResourcePathExtension(resource.path); + const typeLabel = projectResourceTypeLabel(resource); + return extension ? `${typeLabel} · ${extension.toUpperCase()}` : typeLabel; + })(); const visual = (() => { + // 引擎资源的三个分支与资源卡同源(判据都来自 `projectResourceCardPreviewKind`), + // 差别只在尺寸:面板里的缩略图更大,因此模型缩略图按面板几何渲染。 + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + {engineBadgeLabel} + + ); + } + if (kind === 'structured' || kind === 'binary') { + const structuredText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; + return structuredText ? ( + + {structuredText} + + ) : ( + + {engineBadgeLabel} + + ); + } const jsonPresentation = resource ? projectResourceJsonPresentation(resource, preview) : null; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..fec1f8dc6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -17,6 +17,7 @@ import { import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, + Box, Crosshair, Eye, FileCode2, @@ -299,6 +300,8 @@ import { projectResourceCodeTypeLabel, projectResourceDocumentPreviewText, projectResourceJsonPresentation, + projectResourcePathExtension, + projectResourceStructuredPreviewText, } from './resourceCardPreviewModel'; import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { @@ -331,6 +334,9 @@ import { ResourceInfoFieldsView, ResourceInfoPanelView, } from './ResourceInfoPanelView'; +import { ResourceModelPreview } from './ResourceModelPreview'; +import { ResourceModelPreviewDialog } from './ResourceModelPreviewDialog'; +import { resourceModelThumbnailIdentity } from './resourceModelThumbnail'; import { ResourcePreviewMedia } from './ResourcePreviewMedia'; import { type ProjectAgentResultSummary, @@ -803,6 +809,25 @@ const ResourceCard = memo(function ResourceCard({ previewVariant === 'markdown', ) : ''; + /** + * 引擎资源的卡面口径。 + * + * - `engineTypeLabel`:模型 / 动画 / 材质 这类**引擎语义**的类型名(按扩展名判定, + * 不复用"图片 / 文档"的通用角标); + * - `structuredPreviewText`:场景、预制体、动画剪辑等序列化资源的结构摘要;原生判定为 + * 二进制变体时(`content === undefined`)为空串,卡面画类型卡而不是报错。 + */ + const engineTypeLabel = projectResourceTypeLabel(resource); + const engineExtension = projectResourcePathExtension(resource.path); + const engineBadgeLabel = engineExtension + ? `${engineTypeLabel} · ${engineExtension.toUpperCase()}` + : engineTypeLabel; + const structuredPreviewText = + kind === 'structured' + ? projectResourceStructuredPreviewText( + preview.status === 'loaded' ? preview.preview.content : undefined, + ) + : ''; useEffect(() => { const element = cardRef.current; @@ -832,6 +857,66 @@ const ResourceCard = memo(function ResourceCard({ }, [previewIdentity, sourceUrl]); const visual = (() => { + /* + * 引擎资源三个分支排在图片 / 视频 / 音频之前: + * - `model`:交给单例模型渲染器出缩略图,渲染不出来就地降级成类型卡; + * - `structured`:显示结构摘要(场景节点数、动画时长、材质类型…),没有可读正文时同样是类型卡; + * - `binary`:客户端解不了的容器,直接画类型卡,不读字节。 + */ + if (kind === 'model') { + return sourceUrl ? ( + + ) : ( + + + ); + } + if (kind === 'structured') { + return structuredPreviewText ? ( + + + ) : ( + + + ); + } + if (kind === 'binary') { + return ( + + + ); + } if (jsonPresentation) { return ( @@ -1614,6 +1699,14 @@ export default function ProjectDevelopmentView({ const [resourcePanelOpen, setResourcePanelOpen] = useState(false); const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = useState(null); + /** + * 引擎模型的放大预览浮层身份(同时是当前选中资源的预览身份)。 + * + * 与文档预览浮层一样只在 `resources` 视图、且身份仍等于当前选中资源时渲染; + * 它是**只读预览**,不参与编辑、不写回 manifest。 + */ + const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] = + useState(null); /** * 「生成素材」浮层的本次放行类型。 * @@ -6423,10 +6516,18 @@ export default function ProjectDevelopmentView({ ) { setResourceDocumentPreviewIdentity(null); } + if ( + mode !== 'resources' || + uiEditorRoute || + resourceModelPreviewIdentity !== selectedResourcePreviewIdentity + ) { + setResourceModelPreviewIdentity(null); + } }, [ mode, uiEditorRoute, resourceDocumentPreviewIdentity, + resourceModelPreviewIdentity, selectedResourcePreviewIdentity, ]); @@ -6436,6 +6537,12 @@ export default function ProjectDevelopmentView({ return () => protectResourceCardPreview(null); }, [protectResourceCardPreview, resourceDocumentPreviewIdentity]); + useEffect(() => { + if (!resourceModelPreviewIdentity) return; + protectResourceCardPreview(resourceModelPreviewIdentity); + return () => protectResourceCardPreview(null); + }, [protectResourceCardPreview, resourceModelPreviewIdentity]); + /** * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 * @@ -8002,6 +8109,36 @@ export default function ProjectDevelopmentView({ 预览 ) : null} + {/* + * 引擎模型:进「放大预览」浮层做交互式视角操作。 + * 判据与卡面同源(`projectResourceCardPreviewKind`), + * 因此不会出现「卡片是模型卡、却没有 3D 入口」的分叉。 + */} + {selectedResource && + selectedResourcePreviewIdentity && + projectResourceCardPreviewKind( + selectedResource, + ) === 'model' ? ( + } + onClick={() => { + stopActiveCardMedia(); + resourceCardPreviews.requestPreview( + selectedResource, + selectedResourcePreviewIdentity, + 'detail', + ); + setResourceModelPreviewIdentity( + selectedResourcePreviewIdentity, + ); + }} + > + 3D 预览 + + ) : null} {selectedResource && selectedResourceOpensUiEditor ? ( setResourceDocumentPreviewIdentity(null)} /> ) : null} + {mode === 'resources' && + !uiEditorRoute && + selectedResource && + resourceModelPreviewIdentity && + resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? ( + setResourceModelPreviewIdentity(null)} + /> + ) : null} {resourcePanelOpen ? ( => + typeof entry === 'object' && entry !== null && !Array.isArray(entry), + ); + const typeCounts = new Map(); + for (const entry of entries) { + const type = entry['__type__']; + if (typeof type === 'string' && type) { + typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); + } + } + const rootType = entries + .map((entry) => entry['__type__']) + .find( + (type): type is string => typeof type === 'string' && type.length > 0, + ); + if (!rootType) { + return null; + } + const parts: string[] = [rootType]; + const nodeCount = typeCounts.get('cc.Node') ?? 0; + if (nodeCount > 0) { + parts.push(`${nodeCount} 个节点`); + } + const duration = entries + .map((entry) => entry['_duration']) + .find((value): value is number => typeof value === 'number' && value > 0); + if (duration !== undefined) { + parts.push(`${duration.toFixed(2)} 秒`); + } + const name = entries + .map((entry) => entry['_name']) + .find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + if (name) { + parts.push(name); + } + const assetReferences = entries.filter( + (entry) => entry['__uuid__'] !== undefined, + ).length; + if (assetReferences > 0) { + parts.push(`${assetReferences} 处资源引用`); + } + parts.push(`${typeCounts.size} 种类型`); + return parts.join(' · '); +} + +/** + * 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。 + * + * `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。 + */ +export function projectResourceStructuredPreviewText( + content: string | undefined, +): string { + if (content === undefined) { + return ''; + } + return ( + projectResourceCocosStructureSummary(content) ?? + projectResourceDocumentPreviewText(content, false) + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts new file mode 100644 index 000000000..6d74551b8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelScene.ts @@ -0,0 +1,118 @@ +import type * as ThreeTypes from 'three'; + +/** + * 引擎三维模型的**共用场景能力**:加载、取景、释放。 + * + * 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里, + * 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和 + * 释放口径必须逐字一致。 + */ +export type ResourceModelSource = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; +}; + +export function isResourceModelFbx(source: ResourceModelSource) { + return ( + source.mediaType === 'application/octet-stream' || + source.sourceUrl.toLowerCase().includes('.fbx') + ); +} + +/** + * 按媒体类型加载模型。 + * + * 只支持**自包含**的模型:`.glb`、单文件 `.gltf`(buffer 内嵌)、`.fbx`。多文件 glTF + * (`baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方 + * 降级成类型卡,不做静默半渲染。 + */ +export async function loadResourceModelObject( + source: ResourceModelSource, +): Promise { + if (isResourceModelFbx(source)) { + const { FBXLoader } = await import( + 'three/examples/jsm/loaders/FBXLoader.js' + ); + return new FBXLoader().loadAsync(source.sourceUrl); + } + const { GLTFLoader } = await import( + 'three/examples/jsm/loaders/GLTFLoader.js' + ); + const gltf = await new GLTFLoader().loadAsync(source.sourceUrl); + if (!gltf.scene) { + throw new Error('模型内容为空'); + } + return gltf.scene; +} + +/** + * 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。 + * + * 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。 + * 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。 + */ +export function frameResourceModelInCamera( + THREE: typeof ThreeTypes, + object: ThreeTypes.Object3D, + camera: ThreeTypes.PerspectiveCamera, +) { + const box = new THREE.Box3().setFromObject(object); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(maxDimension) || maxDimension <= 0) { + throw new Error('模型几何尺寸无效'); + } + const distance = + (maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35; + camera.position.set( + center.x + distance * 0.55, + center.y + distance * 0.42, + center.z + distance * 0.75, + ); + camera.near = Math.max(distance / 500, 0.001); + camera.far = distance * 20; + camera.lookAt(center); + camera.updateProjectionMatrix(); + return { center, size, maxDimension, distance }; +} + +/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */ +export function addResourceModelLights( + THREE: typeof ThreeTypes, + scene: ThreeTypes.Scene, +) { + scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2)); + const keyLight = new THREE.DirectionalLight(0xffffff, 2.0); + keyLight.position.set(2, 3, 4); + scene.add(keyLight); +} + +/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */ +export function disposeResourceModelObject(object: ThreeTypes.Object3D) { + object.traverse((child) => { + const mesh = child as ThreeTypes.Mesh; + mesh.geometry?.dispose?.(); + const material = mesh.material; + const materials = Array.isArray(material) + ? material + : material + ? [material] + : []; + for (const entry of materials) { + for (const value of Object.values( + entry as unknown as Record, + )) { + const texture = value as + | { isTexture?: boolean; dispose?: () => void } + | undefined; + if (texture?.isTexture && typeof texture.dispose === 'function') { + texture.dispose(); + } + } + (entry as { dispose?: () => void }).dispose?.(); + } + }); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts new file mode 100644 index 000000000..4ab1e0ecd --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceModelThumbnail.ts @@ -0,0 +1,164 @@ +import type * as ThreeTypes from 'three'; + +import { + addResourceModelLights, + disposeResourceModelObject, + frameResourceModelInCamera, + loadResourceModelObject, +} from './resourceModelScene'; + +/** + * 引擎三维模型缩略图渲染器(模块级单例)。 + * + * 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的 + * WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。 + * 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进 + * 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。 + * + * 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个 + * requestAnimationFrame 循环会白烧 GPU 和电量。 + */ +export type ResourceModelThumbnailRequest = { + /** 预览管线给出的 blob URL(模型整份字节)。 */ + sourceUrl: string; + /** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`(FBX)。 */ + mediaType: string; + width: number; + height: number; + /** + * 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。 + * + * blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成 + * 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。 + */ + identity: string; +}; + +const THUMBNAIL_CACHE_LIMIT = 24; +const thumbnailCache = new Map(); + +type RendererBundle = { + renderer: ThreeTypes.WebGLRenderer; + scene: ThreeTypes.Scene; + camera: ThreeTypes.PerspectiveCamera; +}; + +let rendererPromise: Promise | null = null; +let renderQueue: Promise = Promise.resolve(); + +export function resourceModelThumbnailCacheSize() { + return thumbnailCache.size; +} + +/** + * 缩略图缓存键:**不含 blob URL**。 + * + * 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里, + * 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。 + */ +export function resourceModelThumbnailCacheKey( + request: Pick< + ResourceModelThumbnailRequest, + 'mediaType' | 'identity' | 'width' | 'height' + >, +) { + return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`; +} + +export function resourceModelThumbnailIdentity(input: { + resourceKey: string; + path: string; + byteLen?: number; +}) { + return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`; +} + +function rememberThumbnail(cacheKey: string, dataUrl: string) { + thumbnailCache.delete(cacheKey); + thumbnailCache.set(cacheKey, dataUrl); + while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) { + const oldest = thumbnailCache.keys().next().value; + if (oldest === undefined) { + break; + } + thumbnailCache.delete(oldest); + } +} + +async function createRendererBundle(): Promise { + // 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。 + const THREE = await import('three'); + const canvas = document.createElement('canvas'); + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + // 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。 + preserveDrawingBuffer: true, + }); + renderer.setClearColor(0x000000, 0); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000); + addResourceModelLights(THREE, scene); + return { renderer, scene, camera }; +} + +function rendererBundle() { + rendererPromise ??= createRendererBundle().catch((error: unknown) => { + // 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。 + rendererPromise = null; + throw error; + }); + return rendererPromise; +} + +async function renderOnce( + request: ResourceModelThumbnailRequest, +): Promise { + const THREE = await import('three'); + const { renderer, scene, camera } = await rendererBundle(); + const width = Math.max(64, Math.round(request.width)); + const height = Math.max(64, Math.round(request.height)); + const object = await loadResourceModelObject(request); + try { + scene.add(object); + /** + * 相机宽高比必须跟着这次渲染的像素尺寸走。 + * + * 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时, + * 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里 + * 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。 + */ + camera.aspect = width / height; + frameResourceModelInCamera(THREE, object, camera); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + renderer.render(scene, camera); + return renderer.domElement.toDataURL('image/png'); + } finally { + scene.remove(object); + disposeResourceModelObject(object); + } +} + +/** + * 取一张模型缩略图(同一份字节只渲染一次)。 + * + * 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。 + */ +export async function renderResourceModelThumbnail( + request: ResourceModelThumbnailRequest, +): Promise { + const cacheKey = resourceModelThumbnailCacheKey(request); + const cached = thumbnailCache.get(cacheKey); + if (cached) { + rememberThumbnail(cacheKey, cached); + return cached; + } + const task = renderQueue.then(() => renderOnce(request)); + // 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。 + renderQueue = task.catch(() => undefined); + const dataUrl = await task; + rememberThumbnail(cacheKey, dataUrl); + return dataUrl; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts index 998c5b9f5..4e305c3b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceProjectionModel.ts @@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel = | 'Agent 回执' | '项目版本' | '游戏代码' + | '模型' + | '动画' + | '骨骼动画' + | '场景' + | '预制体' + | '瓦片地图' + | '材质' + | '特效' + | '图集' + | '位图字体' + | '纹理' + | '自动图集' + | '音频片段' + | '二进制' | '未知'; const documentExtension = @@ -82,6 +96,20 @@ const gameCodeExtension = /\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu; const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu; const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu; +/* + * 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表 + * (`bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`) + * 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。 + */ +/** 三维模型与网格数据。 */ +const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu; +/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */ +const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu; +/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */ +const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu; +/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */ +const engineStructuredExtension = + /\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu; const gameCodeKind = /(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu; const artKind = @@ -132,10 +160,17 @@ export function projectedResourceKind(input: { if ( normalizedMediaType.startsWith('image/') || normalizedMediaType.startsWith('video/') || - artExtension.test(normalizedPath) + artExtension.test(normalizedPath) || + engineModelExtension.test(normalizedPath) || + engineImageContainerExtension.test(normalizedPath) ) { return 'art'; } + // 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`, + // 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。 + if (engineMaterialExtension.test(normalizedPath)) { + return 'code'; + } if ( normalizedMediaType === 'text/html' || normalizedMediaType === 'text/css' || @@ -149,7 +184,8 @@ export function projectedResourceKind(input: { normalizedMediaType.includes('json') || normalizedMediaType.includes('yaml') || normalizedMediaType.startsWith('text/') || - documentExtension.test(normalizedPath) + documentExtension.test(normalizedPath) || + engineStructuredExtension.test(normalizedPath) ) { return 'document'; } @@ -203,6 +239,55 @@ export function projectResourceTypeLabel( if (subtype === 'agent-result') { return 'Agent 回执'; } + /* + * 引擎资源的类型角标先按扩展名判定。 + * + * 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 → + * `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推 + * 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。 + */ + if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) { + return '模型'; + } + if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) { + return '动画'; + } + if (/\.(skeleton|skel|dbbin)$/iu.test(path)) { + return '骨骼动画'; + } + if (/\.(scene|fire|terrain)$/iu.test(path)) { + return '场景'; + } + if (/\.prefab$/iu.test(path)) { + return '预制体'; + } + if (/\.tmx$/iu.test(path)) { + return '瓦片地图'; + } + if (/\.(mtl|material|pmtl)$/iu.test(path)) { + return '材质'; + } + if (/\.(effect|chunk)$/iu.test(path)) { + return '特效'; + } + if (/\.(plist|atlas|labelatlas)$/iu.test(path)) { + return '图集'; + } + if (/\.fnt$/iu.test(path)) { + return '位图字体'; + } + if (/\.(texture|cubemap|rt)$/iu.test(path)) { + return '纹理'; + } + if (/\.pac$/iu.test(path)) { + return '自动图集'; + } + if (/\.pcm$/iu.test(path)) { + return '音频片段'; + } + if (/\.bin$/iu.test(path)) { + return '二进制'; + } if (kind === 'code') { return '游戏代码'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 729658ceb..508a16e11 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -95,6 +95,12 @@ function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) { if (kind === 'code') { return '游戏代码'; } + if (kind === 'structured') { + return '引擎资源'; + } + if (kind === 'model') { + return '模型'; + } return '资源'; } @@ -532,6 +538,22 @@ export function useProjectResourceCardPreviews(input: { }, ); } + /** + * 引擎序列化资源走独立读取:准入白名单与服务 UI 编辑器的那份**分开**, + * 且允许「不是 UTF-8」的文件正常返回 `content: null`(卡面降级成类型卡), + * 而不是把它报成一次预览失败。 + */ + if (kind === 'structured') { + return invoke( + 'read_local_project_structured_preview', + { + projectPath: input.projectPath, + relativePath: job.resource.path, + scopeId: job.scopeId, + requestId: createProjectResourcePreviewRequestId(), + }, + ); + } return invoke( 'read_local_project_media_preview', { @@ -643,6 +665,9 @@ export function useProjectResourceCardPreviews(input: { if ( kind === 'version' || kind === 'placeholder' || + // 类型卡不读字节:客户端解不了的容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM) + // 读进来也画不出东西,占读取槽只会挤掉真正能出图的卡片。 + kind === 'binary' || (kind === 'audio' && reason !== 'play') ) { return; @@ -1090,7 +1115,12 @@ export function useProjectResourceCardPreviews(input: { continue; } const kind = projectResourceCardPreviewKind(resource); - if (kind === 'version' || kind === 'placeholder' || kind === 'audio') { + if ( + kind === 'version' || + kind === 'placeholder' || + kind === 'audio' || + kind === 'binary' + ) { continue; } const element = elementsByIdentity.get(identity); @@ -1122,7 +1152,8 @@ export function useProjectResourceCardPreviews(input: { identity && kind !== 'version' && kind !== 'placeholder' && - kind !== 'audio' + kind !== 'audio' && + kind !== 'binary' ) { requestPreview(resource, identity, 'visible'); fallbackRequested += 1; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 636ec3d74..6c6d4d9b9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3460,8 +3460,27 @@ export function registerProjectWorkbenchFoundationTests() { resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); - // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。 - expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s); + /* + * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。 + * + * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding + * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边 + * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致, + * 视觉上仍"本体无描边"。 + */ + expect(styles).toMatch( + /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s, + ); + // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。 + expect(styles).toMatch( + /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border-width:/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s, + ); expect(styles).toMatch( /\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s, ); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx new file mode 100644 index 000000000..81f031887 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ + children, + ariaLabel, + }: { + children: ReactNode; + ariaLabel: string; + }) => ( +
+ {children} +
+ ), +})); + +import { + projectResourceCardPreviewKind, + projectResourceCardPreviewReadsContent, + projectResourceCocosStructureSummary, + projectResourceMediaPreviewCategory, + projectResourceStructuredPreviewText, +} from '../src/view/project-development/resourceCardPreviewModel'; +import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview'; +import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog'; +import { + resourceModelThumbnailCacheKey, + resourceModelThumbnailIdentity, +} from '../src/view/project-development/resourceModelThumbnail'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; +import { + projectedResourceKind, + projectResourceTypeLabel, +} from '../src/view/project-development/resourceProjectionModel'; + +type EngineCase = { + path: string; + mediaType: string; + kind: string; + projectedKind: 'art' | 'audio' | 'code' | 'document'; + previewKind: + | 'model' + | 'structured' + | 'binary' + | 'media-image' + | 'audio' + | 'document' + | 'code'; + typeLabel: string; + readsContent: boolean; +}; + +/** + * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。 + * + * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行 + * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。 + */ +const ENGINE_CASES: EngineCase[] = [ + { + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.gltf', + mediaType: 'model/gltf+json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.fbx', + mediaType: 'application/octet-stream', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.mesh', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.skeleton', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '骨骼动画', + readsContent: true, + }, + { + path: 'assets/anim/walk.anim', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/anim/graph.animgraph', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/scene/main.scene', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '场景', + readsContent: true, + }, + { + path: 'assets/scene/enemy.prefab', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '预制体', + readsContent: true, + }, + { + path: 'assets/map/level.tmx', + mediaType: 'application/xml', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '瓦片地图', + readsContent: true, + }, + { + path: 'assets/mtl/hero.mtl', + mediaType: 'application/json', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '材质', + readsContent: true, + }, + { + path: 'assets/shader/glow.effect', + mediaType: 'text/plain', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '特效', + readsContent: true, + }, + { + path: 'assets/atlas/hero.plist', + mediaType: 'application/xml', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '图集', + readsContent: true, + }, + { + path: 'assets/font/bitmap.fnt', + mediaType: 'text/plain', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '位图字体', + readsContent: true, + }, + { + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/height.hdr', + mediaType: 'image/vnd.radiance', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/hero.texture', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '纹理', + readsContent: false, + }, + { + path: 'assets/spine/hero.skel', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '骨骼动画', + readsContent: false, + }, + { + path: 'assets/audio/voice.pcm', + mediaType: 'audio/pcm', + kind: 'audio', + projectedKind: 'audio', + previewKind: 'binary', + typeLabel: '音频片段', + readsContent: false, + }, +]; + +describe('Cocos 资源在资源画布上的准入与预览契约', () => { + it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { + for (const entry of ENGINE_CASES) { + const resource = { + id: entry.path, + path: entry.path, + mediaType: entry.mediaType, + subtype: entry.kind, + }; + expect( + projectedResourceKind({ + path: entry.path, + mediaType: entry.mediaType, + kind: entry.kind, + }), + `${entry.path} 必须能进画布`, + ).toBe(entry.projectedKind); + expect(projectResourceCardPreviewKind(resource), entry.path).toBe( + entry.previewKind, + ); + expect(projectResourceTypeLabel(resource), entry.path).toBe( + entry.typeLabel, + ); + expect( + projectResourceCardPreviewReadsContent(resource), + `${entry.path} 是否能读字节`, + ).toBe(entry.readsContent); + } + }); + + it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { + expect( + projectResourceMediaPreviewCategory({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ).toBe('model'); + expect( + projectResourceMediaPreviewCategory({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ).toBe('art'); + }); +}); + +describe('Cocos 序列化资源的结构摘要', () => { + it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => { + const summary = projectResourceCocosStructureSummary( + JSON.stringify([ + { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 }, + { __type__: 'cc.Node', _name: 'root' }, + { __type__: 'cc.Node', _name: 'child' }, + { __uuid__: 'abc' }, + ]), + ); + expect(summary).toContain('cc.AnimationClip'); + expect(summary).toContain('2 个节点'); + expect(summary).toContain('1.25 秒'); + expect(summary).toContain('walk'); + expect(summary).toContain('1 处资源引用'); + }); + + it('不是序列化数组时不做结构摘要,回退到前几行文本', () => { + const effect = 'CCEffect %{\n techniques: []\n}'; + expect(projectResourceCocosStructureSummary(effect)).toBeNull(); + expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect'); + }); + + it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => { + expect(projectResourceStructuredPreviewText(undefined)).toBe(''); + }); +}); + +describe('模型卡渲染失败时的降级', () => { + it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => { + const identity = resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 1024, + }); + const first = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + const second = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + expect(first).toBe(second); + // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。 + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity: resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 2048, + }), + width: 320, + height: 240, + }), + ).not.toBe(first); + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 480, + height: 360, + }), + ).not.toBe(first); + }); + + it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => { + // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。 + render( + , + ); + expect(screen.getByText('模型 · GLB')).toBeDefined(); + await waitFor(() => { + expect( + document.querySelector('[data-model-preview-status="failed"]'), + ).not.toBeNull(); + }); + }); +}); + +describe('模型放大预览浮层', () => { + const modelResource: ProjectResource = { + id: 'asset:model', + path: 'assets/model/cube.glb', + label: 'cube.glb', + mediaType: 'model/gltf-binary', + category: 'scene', + subtype: 'scene', + manifestAssetId: 'asset:model', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; + + it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => { + const onRequestPreview = vi.fn(); + render( + undefined} + />, + ); + expect(onRequestPreview).toHaveBeenCalledWith( + modelResource, + 'asset:model|assets/model/cube.glb|1548', + 'detail', + ); + expect(screen.getByRole('status').textContent).toContain('正在加载模型'); + expect(document.body.textContent).toContain('左键拖拽旋转'); + expect(document.body.textContent).toContain('滚轮缩放'); + expect(document.body.textContent).toContain('复位视角'); + }); + + it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => { + // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。 + render( + undefined} + onClose={() => undefined} + />, + ); + await waitFor(() => { + expect( + document.querySelector('[data-model-viewer-status="failed"]'), + ).not.toBeNull(); + }); + expect(document.body.textContent).toContain('无法渲染三维预览'); + }); + + it('预览失败时给出错误与重试,不留下空白浮层', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + expect(screen.getByRole('alert').textContent).toContain('模型预览只支持'); + expect(screen.getByText('重试')).toBeDefined(); + }); +}); diff --git a/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md b/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md new file mode 100644 index 000000000..2eba81369 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md @@ -0,0 +1,57 @@ +# 【里程碑】资源画布支持引擎资源预览 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented; 真机 Cocos 工程验收待补 | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` | + +## 目标 + +引擎(Cocos Creator 等)工程里已经存在的资源,可以被登记进 manifest,并作为资源画布的卡片**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡。 + +## 范围 + +- 登记与发现:模型、动画、场景/预制体/瓦片地图/地形、材质/特效、图集与字体配置、图像容器、引擎二进制容器。 +- 生成目录过滤:Cocos 工程的 `library/`、`temp/`、`profiles/`、`local/` 不进发现结果(仅当当前目录确实是引擎工程时生效)。 +- 画布准入与卡面:新增「引擎资源」三个卡面分支(模型 / 结构摘要 / 类型卡),并按扩展名给出引擎语义的类型角标。 +- 模型放大预览:选中模型卡后用工具条的「3D 预览」打开浮层,给出与三维建模软件一致的视角操作(左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角)。 +- 只读预览通道:模型字节读取、引擎序列化文本读取(非 UTF-8 时降级)、图像容器原生转码。 + +## 不在范围内 + +- 引擎资源的编辑、派生、生成与回写(画布上仍是只读预览;「快速编辑」等现有工具链不承接这些类型)。 +- 引擎私有格式的解码:压缩纹理(`.texture` / `.cubemap` / `.rt`)、Spine 二进制(`.skel`)、DragonBones 二进制(`.dbbin`)、PSD、EXR、裸 PCM 只出类型卡。 +- 新增 manifest 契约字段(`cocosUuid` 等)、新增 canonical kind、新增画布分类轴:本轮复用既有 kind,避免旧客户端读不出 manifest(`deny_unknown_fields`)。 +- 多文件 glTF(`baseURI` 指向外部 `.bin` / 贴图)的渲染:卡面只渲染自包含的 `.glb` / 单文件 `.gltf` / `.fbx`,其余降级成类型卡(本轮选择放宽字节上限,不做资源路径解析)。 +- 项目快照 / 版本指纹 / 检查点对生成目录的口径:本轮只过滤**发现结果**,不改快照语义。 + +## 依赖与前置条件 + +- 现有四道闸门:发现(`bridge_project_file_class`)、登记(`agent_local_project_file_type`)、画布准入(`projectedResourceKind`)、卡面读取(`resourceCardPreviewModel` + 原生预览命令)。 +- 既有预览管线(可见性门禁、3 槽并发、LRU 预算、取消与失败语义)不改口径。 +- 前端新增 `three` 运行时依赖(缩略图渲染器)与 `@types/three` 类型依赖。 + +## 验收标准 + +- [x] 表内引擎资源都能通过发现层拿到非空 `mediaType`,并按 `model` / `binary` / `image` / `document` / `audio` 等类别被筛出。 +- [x] 表内引擎资源都能登记进 manifest,`kind` 只落在既有 canonical 词表内。 +- [x] 表内引擎资源都能进入资源画布,并落到预期的卡面分支与类型角标。 +- [x] 模型卡在渲染不可用时降级成类型卡,不冒泡成预览失败。 +- [x] 引擎序列化资源的非 UTF-8 变体降级成类型卡,不报错。 +- [x] 图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)由原生侧转码成 PNG 后按图片卡显示。 +- [x] 引擎工程的生成目录不出现在发现结果里,同名目录在非引擎工程里照常列出。 +- [x] 模型预览上限放宽到与通用媒体预览一致(32 MiB),超出后仍是类型卡而不是半渲染。 +- [x] 模型卡可以放大到独立浮层里交互查看:拖动与滚轮都真实改变画面,「复位视角」回到打开时的取景。 +- [x] 真机 Cocos 工程(含模型与动画资源)在客户端内滚动浏览的视觉验收。 + +## 证据要求 + +- 自动化:`cargo check`;`cargo fmt --check`;`cargo test --bin genarrative-ai-game-creator-shell cocos`(9 通过,含发现分类 / 登记 / 提示词 / 插件门禁);`cargo test … resource_inspect::tests`(7 通过:结构化预览与二进制降级、TGA→PNG 转码、模型媒体类型签名 + 既有文本 / SVG / 尺寸用例);`cargo test … agent_asset_import_tests`(9 通过);`cargo test … local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects`;`npx vitest run apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx`(7 通过);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project`(52 文件 / 543 用例);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(450 通过 / 20 跳过);`npm run typecheck`(app);`npm run check:encoding`;`git diff --check`;`npm run check:doc-index`。 +- 运行时(2026-09-17 完成):在真实客户端(`npm run agc` 起的 Tauri 客户端 + WebView2 CDP 驱动)里打开一个**本地临时 Cocos 夹具工程**(含模型 / 动画 / 序列化资源 / TGA / 引擎容器,不入库),经 `import_local_cocos_project` 与受控登记导入 10 个引擎资源后逐栏核对:模型卡出真实三维缩略图(`data-model-preview-status=ready`,卡面是渲染出来的 PNG data URL);`.anim` / `.scene` / `.prefab` / `.plist` / `.effect` 出结构摘要或文本预览;`.tga` 出真实图片(原生转码生效);`.texture` / `.bin` / `.pcm` 出「纹理 · TEXTURE」「二进制 · BIN」「音频片段 · PCM」类型卡。验收截图(本地留存,不入库):场景与环境栏(模型 + 摘要)、文档栏(类型卡)、待归类栏(TGA 转码)、音频栏、模型放大预览。 +- 运行时(目录过滤):同一现场调 `list_local_project_files`,根级 `library/` / `temp/` / `profiles/` **0 条**,而 `assets/library/inside.bin` 正常列出。 +- 运行时(模型交互):同一现场选中 `cube.glb` → 工具条出现「3D 预览」→ 浮层内 `data-model-viewer-status=ready`、canvas 已挂载;真实鼠标拖拽与滚轮各产生一次不同的渲染结果(三张截图 MD5 互不相同),点「复位视角」后的截图与打开时**逐字节一致**(MD5 `A6531331206456C666909F80384B53AD`)。证据:`agc-model-viewer-initial.png` / `agc-viewer-rotated.png` / `agc-viewer-zoomed-out.png` / `agc-viewer-reset.png`。 +- 运行时(卡面几何):同一现场量 `cube.glb` 卡的卡片盒 / 卡面 / 类型角标 / 缩略图四个矩形的 `x/y/w/h`,在**指针移开、悬停、选中**三种状态下**完全一致**(此前悬停会把卡面四边各吃 1px);缩略图渲染尺寸为 `356x252`(布局盒 178×126 × 2 超采样),绘制比 1.4127 与卡面盒 267×189 的比例一致,缩放后不发虚、不裁切。证据:`agc-fix-hover.png` / `agc-fix-selected.png`。 +- 边界:`.meta` 仍然只可发现、不可登记;引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不进发现结果,同名目录在非引擎工程里照常列出;`.pcm` / `.texture` 等容器不发起读取。 +- 未验证:真机客户端内的视觉验收(本机没有可用的 Cocos 工程实例)。Rust 侧其余仍用 `tempfile::tempdir()` 的既有用例在本机仍被 `Windows 安全对象不属于当前用户` 阻断(本次把预览与导入这两个模块的用例改用工程自带的 `crate::tests::canonical_test_tempdir`,它们已能真实跑通)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ddb8c3926..f1edf3f71 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -22,6 +22,21 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 - 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 +## 2026-09-17 资源画布支持引擎资源只读预览 + +- 背景:Cocos Creator 工程里已有的引擎资源(模型、动画、预制体、材质、图集、压缩纹理…)此前在发现层就止步:`.glb` / `.prefab` / `.anim` / `.texture` 等扩展名既不可登记,也不进资源画布,工程导入后画布上只看得到位图、音频与脚本。 +- 决策(范围):本轮只做**只读预览**。引擎资源可以被发现、登记进 manifest、进入资源画布并按类型出预览;不承接编辑、派生、生成与回写,也不解码引擎私有容器(`.texture` / `.cubemap` / `.rt` / `.skel` / `.dbbin` / `.psd` / `.exr` / `.pcm` 只出类型卡)。 +- 决策(契约):**不新增 manifest 契约字段、不新增 canonical kind、不新增画布分类轴**。引擎资源复用既有 kind(模型/场景/预制体/地形 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`,图像容器 → `image`,裸 PCM → `audio`),避免 `deny_unknown_fields` 让旧客户端读不出整份 manifest;引擎语义由**类型角标**(模型 / 动画 / 材质 / 图集 / 纹理…)表达,不复用「图片 / 文档」。 +- 决策(卡面与读取):新增三个卡面分支 —— `model`(`.glb` / `.gltf` / `.fbx`,由单例 WebGL 渲染器出缩略图,整页只保留一个 WebGL 上下文)、`structured`(Cocos 序列化资源的结构摘要,非 UTF-8 变体降级成类型卡而不是报错)、`binary`(不发起任何读取,不占预览读取槽)。图像容器(`.tga` / `.tif` / `.tiff` / `.hdr`)先在原生侧转码成 PNG,再走既有图片预览链路。 +- 边界:`.meta` 等引擎导入侧车文件仍然只可发现、不可登记;发现层新增 `model` / `binary` 两个**发现类别**(不是 manifest kind)。多文件 glTF(外部 `.bin` / 贴图)与超限模型降级成类型卡;预览管线既有语义(可见性门禁、3 槽并发、LRU 预算、取消与重试口径)不变。 +- 决策(发现过滤):引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进发现结果,判定收窄为「工程根直接子目录 + 当前目录确实是 Cocos Creator 工程(`package.json.creator.version` + `assets/`)」。**不放进全局跳过表**:这些名字在别的工程里可能是真实源码目录。过滤落在唯一一份目录遍历(`list_local_project_files_at`)上,因此 Agent 发现、前端资源树与提示词里的未登记清单同步生效;项目快照 / 版本指纹 / 检查点的语义本轮不动。 +- 决策(模型上限与缓存键):模型预览字节上限从 16 MiB 放宽到 32 MiB,与通用媒体预览取同一上限(base64 载荷约 43 MiB);超过上限仍是类型卡,不做半渲染。缩略图缓存键改用**稳定身份**(资源身份 + 路径 + 字节数)而不是 blob URL:预览缓存淘汰后重读同一模型不会重新解析 + 重新渲染。要再往上放宽,必须先把预览载荷换成 Tauri 原始字节通道。 +- 决策(模型放大预览):模型卡可以在工具条打开「3D 预览」独立浮层,浮层内是**交互式视角**(OrbitControls:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角),与三维建模软件同一套操作习惯。加载 / 取景 / 释放三条口径抽到共用模块 `resourceModelScene`,缩略图与浮层不许各写一套;画布上的卡片仍然是静态缩略图并继续共用**唯一**一个 WebGL 上下文,只有打开浮层时才新建交互式上下文,关闭即 dispose。浮层仍是只读预览:不写 manifest、不参与编辑与派生。 +- 验证:`cargo check`、`cargo fmt --check` 通过;`cargo test … cocos` 9 条通过(发现分类 / 登记 / 提示词投影 / 插件门禁);`cargo test … resource_inspect::tests` 7 条通过(含结构化预览与二进制降级、TGA→PNG 转码、模型签名判定);`cargo test … agent_asset_import_tests` 9 条通过;生成目录过滤用例通过(引擎工程过滤、非引擎工程不过滤);`tests/resourceCocosPreviewContract.test.tsx` 7 条通过(含模型卡渲染不可用时的降级、稳定缓存键);`npx vitest run apps/ai-game-creator-shell/tests/resource apps/ai-game-creator-shell/tests/project` 52 文件 / 543 用例通过;`appSurface.test.ts` 450 通过 / 20 跳过;app `tsc --noEmit`、`npm run check:encoding`、`git diff --check`、`npm run check:doc-index` 通过。 +- 真机验收(2026-09-17 补):在真实客户端内打开一个含模型 / 动画 / 序列化资源 / TGA / 引擎容器的 Cocos 夹具工程,模型卡出三维缩略图、序列化资源出结构摘要、TGA 出转码后的真实图片、引擎容器出类型卡;同现场 `list_local_project_files` 对根级 `library/` / `temp/` / `profiles/` 返回 0 条、`assets/library/` 正常列出。证据见里程碑文档「证据要求」。 +- 未验证:本机其余仍用 `tempfile::tempdir()` 的既有 Rust 用例继续被 `Windows 安全对象不属于当前用户` 阻断(与本决策无关;根因与手工夹具相同,已记入 `pitfalls.md`)。 +- 关联文档:`docs/project-memory/plans/【里程碑】资源画布支持引擎资源预览-2026-09-17.md`。 + ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 52b39d70a..be2d7f4b7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,16 @@ - **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。 - **验证**:`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的 `keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。 - **关联**:`apps/ai-game-creator-shell/src/styles.css`(`面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`。 +## 2026-09-17 资源卡「内容跟着边框动」与「模型缩略图被拉伸」是两条不同的几何陷阱 + +- **内容跟着状态边框位移**:卡片底态是 `border: 0`,悬停 / 选中才加 1px 边框;卡片是 `box-sizing: border-box`,而卡面(`.game-resource-card-visual`)与角标都是 `position: absolute; inset: 0`(包含块 = **padding box**)⇒ 状态一切换,内容盒四边各被吃掉 1px,卡面与角标整体位移并缩小 2px。修法:底态写成 `border: 1px solid transparent;`,状态只点亮 `border-color`;契约用例改成断言「资源卡规则里不得出现非 1px 的 `border` / `border-width`」。 +- **模型缩略图看起来被拉伸 / 被裁**:三个原因叠在一起 —— ① 缩略图渲染器的 `PerspectiveCamera` 是单例复用件,`aspect` 默认 `1` 且从没更新,方形投影被塞进宽扁缓冲;② 卡面里 `width/height: 100%` 的图片挂在 `place-items: center` 的网格里,网格项高度会退化成"按内容定高"(百分比高度解析成 `auto`),图片按自然比例长过卡片、被 `overflow: hidden` 裁掉,看起来就像被拉伸;③ 栏目画布用 `transform: scale(var(--resource-section-zoom))` 放大(真机 1.5 倍),而 `ResizeObserver` / `offsetWidth` 只看**布局盒**,缩放变化既不触发 observer,按布局尺寸 1:1 渲染的图也会被放大到发虚。修法:渲染前设 `camera.aspect`;图片改 `position: absolute; inset: 0` + `object-fit: contain`;渲染尺寸取 `offsetWidth/offsetHeight × 2` 超采样(上限 1024),并把实际渲染尺寸暴露到 `data-model-render-size` 方便排障。 + +## 2026-09-17 从提权会话创建的目录会被 AGC 的 Windows owner 校验直接拒绝 + +- **现象**:在 Codex 会话里手工创建的工程目录(例如 `C:\Users\\Documents\Codex\...\cocos-preview-fixture`),用客户端打开时报 `Windows 安全对象不属于当前用户:`;Rust 侧同样用 `tempfile::tempdir()` 建夹具的用例也成片失败在同一句上。 +- **原因**:这个 shell 以管理员身份运行,`New-Item` / `tempfile` 新建目录的 owner 是 `BUILTIN\Administrators`,而 AGC 的校验要求 owner 等于当前用户 SID(`KDLETTERS\`)。`Get-Acl | Select Owner` 与 `whoami` 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。 +- **处理**:手工夹具先 `icacls /setowner "\" /T`;Rust 用例改用工程自带的 `crate::tests::canonical_test_tempdir(prefix)`(它会 canonicalize 并重置目录 owner),不要直接用 `tempfile::tempdir()`。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。 ## DirectProject 历史不能按工具条目切页再按消息推进游标 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 550e75f0c..fe8462063 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -152,7 +152,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 -- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档和代码文件与其它文件;Agent 只能提交前者。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 +- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档、代码与**引擎资源**(Cocos Creator 的模型、动画、场景/预制体、材质/特效、图集与压缩纹理容器)与其它文件;Agent 只能提交前者。引擎资源在资源画布上是**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡,不承接编辑与派生;`.meta`、`library/`、`temp/` 等引擎生成物仍然只可发现、不可登记。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 - Runtime `asset.list` 与 `file.list` 的详情使用文件上下文上限,而不是普通工具短摘要上限,确保有界候选/目录清单不会因前部内容较长而整体丢失;`asset.list` 超出 48 项或 `file.list` 超出 40 项时仍显式返回剩余数量,Agent 再按候选父目录(例如 `assets`、`game/assets`)缩小范围查询。 - 结果仅返回成功/跳过/失败数量、安全 ID、相对路径、来源、脱敏失败摘要和实际 `revisionAdvanceCount`;幂等跳过不得虚增 revision,部分失败仍须准确记录已发生的 revision 变化。 - 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。 diff --git a/package-lock.json b/package-lock.json index ccb4be9ec..ab6ee8ab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -134,6 +135,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" @@ -1497,6 +1499,13 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -8234,6 +8243,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8449,6 +8465,28 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8461,6 +8499,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -12678,6 +12723,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -15697,6 +15749,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", @@ -23896,6 +23955,12 @@ "react-icons": "^5.4.0" } }, + "@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true + }, "@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -26475,6 +26540,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", @@ -26489,6 +26555,7 @@ "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.14", + "three": "^0.184.0", "typescript": "~5.8.2", "vite": "^6.2.0", "vitest": "^0.34.6", @@ -28333,6 +28400,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -28531,6 +28604,26 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "dev": true }, + "@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true + }, + "@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "requires": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -28541,6 +28634,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, + "@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true + }, "@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -31379,6 +31478,12 @@ "integrity": "sha512-e6eB7zN6UBSwGVwrbWVH+gdLnkW9WwHhmq2YDK1Sh30pzx1onRVGBvogTlUeWxwTa+L86NYdo4hFkh7O8ZjSnA==", "dev": true }, + "fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -33354,6 +33459,12 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true + }, "metro": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", From fce546403f988e0c590b7eb3fb98b8150174dc89 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 17 Sep 2026 20:23:55 +0800 Subject: [PATCH 45/68] =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=AE=B0=E5=BD=95=20Je?= =?UTF-8?q?nkins=E3=80=81=E9=A2=84=E8=A7=88=E6=8E=A7=E5=88=B6=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88=E5=AE=9E=E4=BE=8B=E7=9A=84=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E5=85=A5=E5=8F=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在【开发运维】本地开发验证与生产运维补充 jenkins.genarrative.world 与 build.genarrative.world 的公网入口、反向隧道端口、白名单依赖与排障顺序 - 在 shared-memory/decision-log.md 记录 Jenkins 公网入口、预览部署控制面公网入口、预览控制面公网预览地址口径三组决策及各自验证证据 --- .../shared-memory/decision-log.md | 27 +++++++++++++++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ 2 files changed, 29 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f1edf3f71..1b57435a0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8864,3 +8864,30 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:Deploy 阶段在远端 dev / release agent 执行,不受该上限约束。调整只动这两处:`systemctl set-property / revert jenkins.service`、`docker update --cpus= gitea-runner` 加同步 compose(备份 `/opt/gitea-stack/compose.yml.bak-<时间戳>`)。 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 Jenkins 公网入口 jenkins.genarrative.world 复用 router 反向隧道口径 + +- 背景:Jenkins controller 实际与 Gitea 同机运行在 `genarrative-station`(`jenkins.service`,`--httpPort=8080 --prefix=/jenkins`,`JENKINS_HOME=/var/lib/jenkins`),此前只有内网入口 `http://192.168.35.82:8080/jenkins/`;`router.genarrative.world` 已有「dev Nginx → dev loopback → station 反向隧道」的成熟口径。 +- 决策:沿用 router 口径,不新增网关组件。`genarrative-station` 的 `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18085:127.0.0.1:8080`(dev loopback `18085` → station Jenkins `127.0.0.1:8080`);dev 新增 `/etc/nginx/conf.d/jenkins.genarrative.world.conf`:`80` 只做 ACME webroot 与 `301`,`443` 用 Certbot 证书反代 `http://127.0.0.1:18085` 并保留 `Upgrade` / `X-Forwarded-*`;证书按 router 口径用 `certbot certonly --webroot -w /var/www/html -d jenkins.genarrative.world --renew-hook 'systemctl reload nginx'` 申请。 +- 路径口径:Jenkins 固定 `--prefix=/jenkins`,域名根路径 `302` 到 `https://jenkins.genarrative.world/jenkins/login`,`/jenkins` 补斜杠,其余未带前缀路径 `302` 到 `/jenkins$request_uri`;证书不复制到 Pingora 私有目录,公网 `80/443` 仍由 dev Nginx 监听。 +- 边界:本次只暴露 HTTP/HTTPS UI,`slaveAgentPort` 保持 `-1`(agent 继续由 Jenkins 用 SSH launcher 连 dev / release),不改 Jenkins `jenkinsUrl` 与鉴权策略;Jenkins 登录页因此进入公网可达面,访问控制继续依赖 Jenkins 自身账号体系。 +- 验证:dev `nginx -t` 与 `systemctl reload nginx` 通过;`curl -sI https://jenkins.genarrative.world/` 返回 `302 /jenkins/login`、`/jenkins/login` 返回 `200`、登录页静态资源 `200`、`http://` 入口 `301`;Let's Encrypt 证书 `CN=jenkins.genarrative.world` 到期 `2026-12-16`;公网探测 `82.157.175.59` 仍只开放 `80/443/22`。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 + +## 2026-09-17 预览部署控制面公网入口 build.genarrative.world + +- 背景:多人内网预览控制面(`preview-deployer-server` + `shared/Genarrative-Preview-Deployer` Job)此前只在内网 `http://192.168.35.82/build/` 提供,2026-08-15 决策明确「不配置公网域名」;本次要求给它加公网入口。 +- 决策:沿用 router / Jenkins 同一口径新增 `build.genarrative.world` 作为控制面公网入口,并把 `preview.genarrative.world` 作为 `*.preview.genarrative.world` 规划的父域名先落一张落地页(实例本身仍只在内网)。station `gitea-reverse-tunnel.service` 增加 `-R 127.0.0.1:18086:127.0.0.1:8410`;dev 新增 `/etc/nginx/conf.d/build.genarrative.world.conf`(`80` ACME+`301`,`443` 把 `/build/`、`/api/preview-deployer/` 反代到 `127.0.0.1:18086`,`/` 跳 `/build/`,公网侧 `proxy_cookie_flags ~ secure`)与 `/etc/nginx/conf.d/preview.genarrative.world.conf`(单域名证书 + 落地页)。 +- 白名单:控制面按 `Host` 精确匹配、对非 GET 的 `/api/*` 精确匹配 `Origin`,因此同步改为 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS=192.168.35.82,build.genarrative.world`、`GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS=http://192.168.35.82,https://build.genarrative.world`;`GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`(内网 HTTP 入口继续可用),公网 cookie 的 `Secure` 由 dev nginx 强制。重启 `genarrative-preview-deployer.service` 会清空内存会话,内网用户需重新输入口令。 +- 边界:本次只暴露控制面(触发/查看构建、卸载),预览实例不暴露;`preview.genarrative.world` 没有通配记录,实例地址仍是内网 `http://192.168.35.82:84xx`,控制面页面展示的 `webUrl` 也仍是内网地址。要变成公网实例地址,需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 URL 口径改造。 +- 验证:`nginx -t` 与 reload 通过;`https://build.genarrative.world/` `302 → /build/`、`/build/` `200`、SPA 资源 `200`、`/api/preview-deployer/session` 返回 `{"authenticated":false}`;错误或缺失 `Origin` 的 POST `403`、错误口令 `401`、字段名不符 `422`;两个新域名证书到期 `2026-12-16`;内网 `Host: 192.168.35.82` 仍 `200`、未知 Host `403`;jenkins / dev / git 入口回归正常。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 + +## 2026-09-17 预览控制面增加公网预览地址口径(代码已实现,待随控制面发布) + +- 背景:控制面页面此前只展示内网地址 `http://192.168.35.82:`;公网通配域名 `*.preview.genarrative.world` 已解析到 dev,需要页面能显示对应的公网入口。 +- 决策:公网地址由控制面自己派生,不接受 Jenkins 产物或状态文件提供的任意地址。`preview-deployer-server` 新增可选配置 `GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN`(如 `preview.genarrative.world`,只接受小写字母、数字、短横线和点号,不带协议与端口),并在公开 DTO 新增 `webPublicUrl`:仅当记录已有内网 `webUrl` 时取 `https://.`(实例 ID 仍由分支派生,形如 `preview-<16位hex>`),卸载时与 `webUrl` / `webPort` 一起清空;状态文件里的旧值在加载时被重新派生覆盖。 +- 前端:`apps/preview-deployer-web` 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接;未配置时行为与之前一致。 +- 上线依赖(本次未完成):`*.preview.genarrative.world` 通配证书(Let's Encrypt 通配只能走 DNS-01,域名在 DNSPod,certbot 无官方插件,需要 DNSPod API Token 配合 acme.sh)、station 侧按 Host 分发到 `84xx` 端口、dev 通配 vhost 与隧道;控制面本体需在 station 用 `scripts/deploy/preview-deployer-install.sh` 重建发布。 +- 验证:`cargo test -p preview-deployer-server`(13 项)、`apps/preview-deployer-web` vitest(13 项,含新增公网地址用例)、`npx tsc --noEmit`、`npm run preview-deployer:web:build`(`PREVIEW_DEPLOYER_WEB_BASE=/build/`)、`npm run check:preview-deployer`、`npm run check:encoding`、`git diff --check` 全部通过。 +- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[Jenkins容器预览部署控制面技术方案](../../technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0b5d624d2..f13c743d5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -783,6 +783,8 @@ npm run container:down `npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 多人内网预览入口固定为 `http://192.168.35.82/build/`,不配置公网域名。该独立 Jenkins 容器预览部署控制面不让浏览器直接操作 Docker 或持有 Jenkins Token;SPA 通过同源代理触发固定 `shared/Genarrative-Preview-Deployer` Job。分支和 commit 输入框通过受认证的控制服务搜索固定内网 Git 仓库并展示下拉结果;提交构建前控制服务重新确认分支存在、可选 commit 存在且属于目标分支,失败时不触发 Jenkins,Jenkins checkout 仍保留最终复核。每个分支使用稳定的内部 `deploymentId` 和独立 Compose project,Web 端口从 `8400..8499` 在文件锁内分配,同一分支换 commit 优先复用端口,卸载后释放;页面记录 ID 使用 Jenkins 构建编号。Jenkins 用 `preview-result.json` 向页面提供 resolved commit、发布结果和内网 Web URL,页面刷新时由控制服务实时复核 Web 健康;构建详情链接固定使用局域网 Jenkins 地址,不暴露 loopback 地址。失败/取消且不可卸载的记录保留 7 天,停止记录保留 30 天,仍可卸载的失败记录不会自动清理。安装资产为 `deploy/systemd/genarrative-preview-deployer.service`、`deploy/env/preview-deployer.env.example` 和 `deploy/nginx/genarrative-preview-deployer-lan.conf`;完整合同见 `docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`。 +Jenkins controller 与 Gitea 同机运行在 `genarrative-station`,除内网入口 `http://192.168.35.82:8080/jenkins/` 外,公网入口为 `https://jenkins.genarrative.world/jenkins/`:dev 的 `/etc/nginx/conf.d/jenkins.genarrative.world.conf` 把 `443` 反代到 `http://127.0.0.1:18085`,该 loopback 端口由同机 `gitea-reverse-tunnel.service` 新增的 `-R 127.0.0.1:18085:127.0.0.1:8080` 落到 station 的 `jenkins.service`(`--prefix=/jenkins`,因此域名根路径 `302` 到 `/jenkins/login`)。排障顺序:dev `ss -tlnp | grep 18085` 必须有 `sshd` 监听,`curl -sI https://jenkins.genarrative.world/jenkins/login` 必须 `200`,`systemctl status gitea-reverse-tunnel.service` 必须 `active`,且公网 `80/443` 仍由 Nginx 监听;`slaveAgentPort=-1` 保持关闭,agent 仍走 SSH launcher,不新增入库端口。证书按 router 口径用 Certbot webroot(`/var/www/html`)维护,续期 hook 为 `systemctl reload nginx`。 +预览部署控制面的公网入口为 `https://build.genarrative.world/build/`(`/` 与 `/build` 分别 `302`/`301` 到 `/build/`,API 走同源 `/api/preview-deployer/`):dev 的 `/etc/nginx/conf.d/build.genarrative.world.conf` 反代到 `127.0.0.1:18086`,该 loopback 端口由 station `gitea-reverse-tunnel.service` 的 `-R 127.0.0.1:18086:127.0.0.1:8410` 落到控制面 `preview-deployer-server`。控制面按 `Host` 精确匹配白名单、对非 GET 的 `/api/*` 精确匹配 `Origin`,公网域名必须同时出现在 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_HOSTS` 和 `GENARRATIVE_PREVIEW_DEPLOYER_ALLOWED_ORIGINS` 中;公网 cookie 的 `Secure` 由 dev nginx 的 `proxy_cookie_flags ~ secure` 强制,因此 `GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE` 保持 `false`,内网 `http://192.168.35.82/build/` 的登录态不受影响。排障顺序:dev `ss -tlnp | grep 18086` 有 `sshd` 监听;`curl -s https://build.genarrative.world/api/preview-deployer/session` 返回 `{"authenticated":false}`;缺失或错误 `Origin` 的 POST 必须 `403`,错误口令必须 `401`。`preview.genarrative.world` 当前只是通配规划下的落地页,预览实例仍只在内网 `http://192.168.35.82:84xx`;要暴露实例需要 `*.preview.genarrative.world` 通配证书(只能 DNS-01)、station 侧按 Host 分发和页面 `webUrl` 口径改造。 隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并且只使用 unsupported job 验证 worker claim / fail 回写,不覆盖 BgFilter 成功、失败或 fallback 链路,也不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。 独立 BgFilter worker 的本机全进程验证先运行 `cargo build -p api-server --manifest-path server-rs/Cargo.toml`,再依次运行 `npm run bgfilter-worker:smoke-test`、`npm run bgfilter-worker:load-smoke` 和 `npm run bgfilter-worker:fault-smoke`。三条命令只使用动态 loopback 端口、假 OSS 签名配置和本地 mock provider;不会读取仓库 `.env*` 或请求真实 BgFilter / OSS。自定义或 WSL binary 通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定。当前 fault 范围包含 overload、queue deadline、两类 HTTP 状态顺序重试结果,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功;慢读、大响应、父侧客户端断连与 SIGTERM 排空另行验证。 From f5b4293e806a07bf6e80be8f0d0cade2ebb69f46 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 17 Sep 2026 20:24:11 +0800 Subject: [PATCH 46/68] =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=9D=A2=E6=94=AF=E6=8C=81=E5=B1=95=E7=A4=BA=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview-deployer-server 新增可选配置 GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN,并按「实例 ID + 预览域名」派生公开字段 webPublicUrl,卸载时与 webUrl/webPort 一起清空、加载状态文件时重新派生覆盖 - preview-deployer-web 在存在公网地址时把「打开公网预览」作为主入口,内网地址降级为次级链接,未配置时行为不变 - deploy/env/preview-deployer.env.example 补充 WEB_DOMAIN 键与留空语义说明 - 补充 cargo 与 vitest 用例覆盖公网地址派生、域名配置校验与页面展示 --- .../src/PreviewDeployerApp.test.tsx | 26 +++++++++++ .../src/PreviewDeployerApp.tsx | 10 +++++ apps/preview-deployer-web/src/types.ts | 1 + deploy/env/preview-deployer.env.example | 2 + .../preview-deployer-server/src/config.rs | 34 +++++++++++++++ .../crates/preview-deployer-server/src/lib.rs | 27 ++++++++++++ .../preview-deployer-server/src/tests.rs | 43 +++++++++++++++++++ 7 files changed, 143 insertions(+) diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 4a366fdaa..29a4dbb5d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => { expect(await screen.findByText('构建并发布一个分支')).toBeTruthy(); }); +test('prefers the public preview domain when the control plane exposes one', async () => { + vi.mocked(api.listDeployments).mockResolvedValue([ + { + id: '77', + branch: 'feature/public-preview', + resolvedCommit: '1234567890abcdef', + status: 'running', + health: 'healthy', + webPort: 8400, + webUrl: 'http://192.168.35.82:8400', + webPublicUrl: + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + createdAt: 1_787_270_400, + updatedAt: 1_787_270_460, + }, + ]); + render(); + + const publicLink = await screen.findByRole('link', { + name: /打开公网预览/u, + }); + expect(publicLink.getAttribute('href')).toBe( + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + ); +}); + test('submits a branch with an optional commit hash', async () => { const user = userEvent.setup(); render(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index ec5e30773..ea23c3836 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? ( + + 打开公网预览 + + ) : null} {deployment.webUrl ? ( .<后缀>,留空则只展示内网地址。 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false diff --git a/server-rs/crates/preview-deployer-server/src/config.rs b/server-rs/crates/preview-deployer-server/src/config.rs index f693697bb..ff8d637ab 100644 --- a/server-rs/crates/preview-deployer-server/src/config.rs +++ b/server-rs/crates/preview-deployer-server/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub allowed_hosts: Vec, pub allowed_origins: Vec, pub preview_web_host: String, + pub preview_web_domain: Option, pub secure_cookie: bool, pub static_dir: Option, pub state_file: PathBuf, @@ -49,6 +50,7 @@ impl fmt::Debug for Config { .field("allowed_hosts", &self.allowed_hosts) .field("allowed_origins", &self.allowed_origins) .field("preview_web_host", &self.preview_web_host) + .field("preview_web_domain", &self.preview_web_domain) .field("secure_cookie", &self.secure_cookie) .field("static_dir", &self.static_dir) .field("state_file", &self.state_file) @@ -116,6 +118,15 @@ impl Config { .to_string(), ); } + let preview_web_domain = env::var("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| { + validate_preview_web_domain(&value) + .map_err(|reason| format!("GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN {reason}")) + }) + .transpose()?; for origin in &allowed_origins { let parsed = Url::parse(origin).map_err(|_| format!("无效 allowed origin: {origin}"))?; @@ -162,6 +173,7 @@ impl Config { allowed_hosts, allowed_origins, preview_web_host, + preview_web_domain, secure_cookie, static_dir, state_file, @@ -217,6 +229,28 @@ fn validate_state_file(path: &std::path::Path) -> Result<(), String> { Ok(()) } +pub(crate) fn validate_preview_web_domain(value: &str) -> Result { + if value.len() > 253 || !value.contains('.') { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + for label in value.split('.') { + if label.is_empty() || label.len() > 63 { + return Err("必须是不带协议和端口的 DNS 域名".to_string()); + } + let bytes = label.as_bytes(); + if bytes[0] == b'-' || bytes[bytes.len() - 1] == b'-' { + return Err("每段标签不能以短横线开头或结尾".to_string()); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') + { + return Err("只允许小写字母、数字、短横线和点号".to_string()); + } + } + Ok(value.to_string()) +} + fn required(name: &str) -> Result { env::var(name) .ok() diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 223255b1b..e0165799b 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -115,6 +115,8 @@ pub struct Deployment { pub web_port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_public_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub jenkins_build_url: Option, pub created_at: u64, @@ -633,6 +635,7 @@ async fn create_deployment( health: HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -959,6 +962,14 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { if let Some(value) = result.web_port { record.public.web_port = Some(value); } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url( + state.config.preview_web_domain.as_deref(), + &record.instance_id, + ) + } else { + None + }; if let Some(value) = result.health { record.public.health = value; } @@ -1005,6 +1016,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { record.public.health = HealthStatus::Unknown; record.public.web_port = None; record.public.web_url = None; + record.public.web_public_url = None; record.public.can_uninstall = false; if record.public.message.is_none() { record.public.message = Some("预览实例已卸载".to_string()); @@ -1090,6 +1102,11 @@ fn load_deployments(config: &Config) -> Result if record.public.web_port.is_none() { record.public.web_port = url_port; } + record.public.web_public_url = if record.public.web_url.is_some() { + public_web_url(config.preview_web_domain.as_deref(), &record.instance_id) + } else { + None + }; if deployments .insert(record.instance_id.clone(), record) .is_some() @@ -1326,6 +1343,16 @@ fn map_artifact_health(value: &str) -> Option { } } +// 公网入口由控制面自己派生,主机名固定为「实例 ID + 配置的预览域名」, +// 不接受 Jenkins 产物或状态文件提供的任意地址。 +fn public_web_url(domain: Option<&str>, instance_id: &str) -> Option { + let domain = domain?; + if validate_deployment_id(instance_id).is_err() { + return None; + } + Some(format!("https://{instance_id}.{domain}")) +} + fn is_safe_web_url(value: &str, expected_host: &str) -> bool { url::Url::parse(value).is_ok_and(|url| { url.scheme() == "http" diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 98b2b5511..7874d5169 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -171,6 +171,7 @@ fn test_config(jenkins_base_url: Url) -> Config { allowed_hosts: vec![HOST.to_string()], allowed_origins: vec![ORIGIN.to_string()], preview_web_host: "192.168.35.82".to_string(), + preview_web_domain: Some("preview.genarrative.world".to_string()), secure_cookie: false, static_dir: None, state_file: std::env::temp_dir().join(format!( @@ -378,6 +379,10 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { ); assert_eq!(deployment.health, super::HealthStatus::Healthy); assert_eq!(deployment.web_port, Some(8400)); + assert_eq!( + deployment.web_public_url.as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); assert_eq!( deployment.web_url.as_deref(), Some("http://192.168.35.82:8400") @@ -436,6 +441,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { assert_eq!(deployment.health, super::HealthStatus::Unknown); assert_eq!(deployment.web_port, None); assert_eq!(deployment.web_url, None); + assert_eq!(deployment.web_public_url, None); assert!(!deployment.can_uninstall); let list_request = axum::http::Request::builder() @@ -605,6 +611,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { health: super::HealthStatus::Pending, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: now, updated_at: now, @@ -652,6 +659,40 @@ async fn wait_for_status( Err(()) } +#[test] +fn public_web_url_is_derived_from_instance_id_and_configured_domain() { + assert_eq!( + super::public_web_url( + Some("preview.genarrative.world"), + "preview-63d38d3da6bc9b06" + ) + .as_deref(), + Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world") + ); + assert_eq!( + super::public_web_url(None, "preview-63d38d3da6bc9b06"), + None + ); + assert_eq!( + super::public_web_url(Some("preview.genarrative.world"), "feature/demo"), + None + ); +} + +#[test] +fn preview_web_domain_config_is_strict() { + assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok()); + assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err()); + assert!( + super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err() + ); + assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err()); + assert!(super::config::validate_preview_web_domain("preview").is_err()); + assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err()); + assert!(super::config::validate_preview_web_domain("").is_err()); +} + #[test] fn branch_commit_and_web_url_validation_are_strict() { assert!(super::validate_branch("feature/preview-ui").is_ok()); @@ -758,6 +799,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: None, web_url: None, + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, @@ -781,6 +823,7 @@ async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_reta health: super::HealthStatus::Unknown, web_port: Some(8401), web_url: Some("http://192.168.35.82:8401".to_string()), + web_public_url: None, jenkins_build_url: None, created_at: old, updated_at: old, From efce7b102f7d546684a71ee4b7f0ca4f0bc89c9f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:01:26 +0800 Subject: [PATCH 47/68] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E4=B8=BB=E7=BA=BF?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E5=A5=91=E7=BA=A6=E4=B8=8E=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=B5=8B=E8=AF=95=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐图集切分声明与工具目录字段边界断言 更新原生HTTP权限守卫及稳定空快照发布次数期望 修正末尾空行并记录CI排查证据,保留待远程验证状态 --- .../src-tauri/src/tests/project.rs | 6 ++++- .../src-tauri/src/tests/provider.rs | 22 ++++++++++++++++++- .../resourceCanvasGenerationLanding.test.tsx | 2 -- .../tests/workspaceWindowSync.test.tsx | 14 ++++++++---- ...里程碑】画布验收问题统一修复-2026-09-17.md | 3 ++- scripts/check-native-shells.mjs | 1 - 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 4faf39ccc..64f552a15 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -806,7 +806,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + // 图集切分没有默认值:必须显式声明,且与平台回显的 sliceMode/gridX/gridY 一致。 + "sliceMode": "grid", + "gridX": 2, + "gridY": 2 } } ], diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dd4946ea4..d71500895 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6916,9 +6916,29 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); + let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"]; + assert_eq!( + canvas_properties["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); + for field in ["gridX", "gridY", "sliceCount"] { + assert_eq!( + canvas_properties[field]["type"], + serde_json::json!(["integer", "null"]) + ); + assert_eq!(canvas_properties[field]["minimum"], 1); + assert_eq!( + canvas_properties[field]["maximum"], + if field == "sliceCount" { 256 } else { 32 } + ); + } assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"], serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null]) diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index a3d05ff94..a281f6150 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -502,5 +502,3 @@ describe('音频生成身份', () => { ); }, 20_000); }); - - diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx index 457f61417..47471a79b 100644 --- a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -84,8 +84,12 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 await act(async () => { await Promise.resolve(); }); - // 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。 - expect(publications).toHaveLength(2); + /* + * 无原生 invoke 时 active-turn Hook 只有「空态」这一种状态:空快照引用保持稳定 + * (不再 `setActiveTurns([])` 换新数组),`snapshotReadFailed` 也保持 false。 + * 依赖项一个都没变,工作台只应发布一次。 + */ + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( 1, @@ -94,13 +98,15 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且 const latestOpen = vi.fn(async () => undefined); homeProjectOverride.openProject = latestOpen; rendered.rerender(view('更改显示名')); - expect(publications).toHaveLength(2); + // 非依赖变化的重渲染不能让窗口面板重新发布。 + expect(publications).toHaveLength(1); act(() => openProject('/tmp/window-latest-project')); expect(latestOpen).toHaveBeenCalledWith( '/tmp/window-latest-project', 'open', ); - expect(publications).toHaveLength(2); + // 回调执行只换 ref 里的实现,发布次数与清理次数都不能变。 + expect(publications).toHaveLength(1); expect(cleanups).toBe(0); rendered.unmount(); expect(cleanups).toBe(1); diff --git a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md index 63a984781..52b061cbc 100644 --- a/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md @@ -42,4 +42,5 @@ - appSurface 两条异步轮询泄漏相关失败已通过用例内部等待后端阶段合并、任务终态及轮询退出修复,未修改生产逻辑或全局 harness。子 Agent 在集成工作树全量复验:450 项通过、20 项跳过、0 失败。 - 画布名称沿用正式落盘文件名,生成素材可能带时间戳前缀与扩展名;未新增另一套显示名状态。 - 在途落点失败且整理无变化时可能留下一个空撤销步骤,失败提示仍可见,不导致正式素材数据丢失;低优后续事项。 -- 需求记录与 Issue/PR 未写回;所有远程写入等待单独确认。 +- 已按确认创建 Issue #411、PR #412,并推送 `feat/agc-canvas-acceptance`;飞书未写回。用户另已授权合入 master、修复 CI 并持续推送到检查通过,不授权合并 PR 本身。 +- 合入 master 后的 CI 2439 暴露五类检查偏差:图集 mock 缺少显式切分参数、工具目录断言未包含新增切分字段、更新插件迁移后的 HTTP 白名单期望过期、窗口空快照稳定化后的发布次数期望过期、测试文件末尾多空行。修复保持现行生产合同和权限范围,补齐参数/边界断言;两个 Rust 失败用例、原生合同门禁及窗口/落点四项前端用例已定向通过,仍需新提交的远程 CI 全量结果。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 6748f9628..7b5447faf 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() { JSON.stringify([ { url: 'https://dev.genarrative.world/api/*' }, { url: 'https://www.genarrative.world/api/*' }, - { url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' }, { url: 'https://*/api/*' }, { url: 'http://localhost:*/*' }, { url: 'http://127.0.0.1:*/*' }, From 0deaabfee66e0871324d5f9e6ecb3125400cc8cd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:28:05 +0800 Subject: [PATCH 48/68] =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=91=98=E8=A6=81=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范记录 0.1.62 渠道清单自动摘要与客户端提示展示的用户实测结论 --- docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 97c7474ea..53233a8de 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -114,6 +114,7 @@ | 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) | | 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) | | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | +| 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | 待执行证据(首次渠道发布后回填): From 133bb2f350cd703e2d64f7b2749f816a956f7617 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:05:18 +0800 Subject: [PATCH 49/68] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20AGC=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20HTTP=20=E5=A5=91=E7=BA=A6=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=B8=8E=E6=9B=B4=E6=96=B0=E6=91=98=E8=A6=81=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约守卫不再要求 http 插件放行 OSS 域名:更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成 - 主规范回填 0.1.62 自动更新摘要与客户端提示展示的用户实测证据 --- scripts/check-native-shells.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 7b5447faf..b7eecde13 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2588,6 +2588,8 @@ function assertAiGameCreatorShellUserDevBoundary() { ]) ) { throw new Error( + // 更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成, + // 不再需要为 webview 的 http 插件放行 OSS 域名。 'AI game creator native HTTP scope must match the release, dev, custom HTTPS, and loopback API boundary', ); } From da44d66dc87798b55c09ae83828a8ba46e83f2d4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:12:42 +0800 Subject: [PATCH 50/68] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=89=B9=E9=87=8F=E8=BF=BD=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=9A=84=E8=A1=8C=E4=B8=BA=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 限定追加语义、冻结对象、原子保存与类型保留边界 记录已评审里程碑及前后端并行实现计划 --- ...【实施计划】多选素材批量标签-2026-09-17.md | 30 +++++++++++++++++ .../【里程碑】多选素材批量标签-2026-09-17.md | 33 +++++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 12 +++++++ 3 files changed, 75 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】多选素材批量标签-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md diff --git a/docs/project-memory/plans/【实施计划】多选素材批量标签-2026-09-17.md b/docs/project-memory/plans/【实施计划】多选素材批量标签-2026-09-17.md new file mode 100644 index 000000000..4968ceb1c --- /dev/null +++ b/docs/project-memory/plans/【实施计划】多选素材批量标签-2026-09-17.md @@ -0,0 +1,30 @@ +# 【实施计划】多选素材批量标签 + +| 字段 | 值 | +| --- | --- | +| Milestone | docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md | +| Status | ready | +| Owner | 主 Agent 集成;deepseek-flash 实现和独立 review | + +## 修改边界 + +- 原生:`project/manifest` 标签批量追加函数和 DTO、`commands.rs` 权限门面、`main.rs` 注册、分类测试。沿用项目写锁、manifest mutation、审计和 revision,不新建状态系统。 +- 前端:扩展现有 `ResourceClassificationPanel` 为单/批量模式,复用已有字段与 pill;`ResourceCanvasPanelView` 增加可选批量标签动作;工作台冻结选择并用完整返回条目更新。前端不得逐素材循环写回。 +- 并行采用不重叠文件集:原生 agent 只写 Rust,前端 agent 只写 TS/TSX/CSS/前端测试;主 Agent 负责文档与集成,禁止跨区提交别人文件。 + +## 顺序与检查点 + +规范经独立 deepseek-flash 评审通过;批量输入仅追加草稿,面板动作行直接增加入口,返回顺序、去重上限、no-op revision 和未知字段拒绝已在主规范明确。 + +1. 规范独立评审,确定命令与错误语义。 +2. 前后端并行实现,均在明确工作区局部修改、自审和定向验证。 +3. 集成后独立 review,再按有效发现返修;不因测试方便而扩产品范围。 +4. 本地中文提交、报告已验证和待真机事项;远程操作留待用户后续确认。 + +## 验证 + +`npm test -- <相关测试>`、AGC 类型检查、目标文件 ESLint、原生 classification 相关测试、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。依赖复用时使用本工作树忽略目录的隔离配置,避免修改主目录依赖。 + +## 风险与回滚 + +批量写不能掩盖部分失败;类型字段不能借标签保存被读时自愈值覆盖。单素材编辑与多素材追加保留明确分支,不重构其它菜单动作;本地独立提交可回退新需求,保留主线合并及既有画布工作。 diff --git a/docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md b/docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md new file mode 100644 index 000000000..8abf8f111 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】多选素材批量标签-2026-09-17.md @@ -0,0 +1,33 @@ +# 【里程碑】多选素材批量标签 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-17 | +| Parent Spec | docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | + +## 目标与范围 + +多选已登记素材后一次追加标签,保留每项原有标签和类型。复用主线菜单与现有标签面板,画布和资源面板均可进入,原生一次校验与保存。 + +## 非目标 + +不处理动画失败、资源面板卡死与未完成编辑恢复;不做聊天引用扩展、批量类型修改、既有标签批量删除或 AI 自动分类。不推送、不触发或监视 CI、不写飞书。 + +## 前置 + +主线菜单 PR #410 已合入;保留本分支前六项画布功能。主规范及本里程碑通过独立评审后进入实现。 + +## 验收 + +- [ ] 两个入口均按完整冻结选择集编辑,不只修改首项。 +- [ ] 原有标签、正式分类和无关素材不变。 +- [ ] 权限/身份/CAS/任意项上限失败时无部分写入;成功一次 revision。 +- [ ] 空操作、重复标签和重复 ID 不重复写入;最大 200 项有界。 +- [ ] 保存锁定、失败草稿保留、切项目迟到响应正确处理。 +- [ ] 单素材标签、菜单角标和画布布局回归通过。 + +## 证据 + +前端组件与宿主整合测试、Rust 原生定向测试、AGC 类型检查、ESLint、编码/文档索引/diff 检查;真实客户端验证单独报告。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index c09b2f654..0e66d53bb 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,17 @@ # AI 游戏创作智能体 App 实施计划 +## 多选素材批量标签 + +- 画布与资源面板共用选中集合。选择至少两项同项目已登记素材后,从已有“编辑标签”入口或资源面板的“批量标签”动作打开同一标签编辑器的批量模式;入口遵循既有“常用操作 + 更多”收纳,不新增平行资源管理页。 +- 批量模式只将用户填写的标签追加到整组选中素材。输入按既有中英文逗号、顿号、换行拆分,trim、去空及去重;每份素材原有标签、正式分类、类型、路径、来源、版本关系均保留。批量删除/替换既有标签、批量改素材类型及 AI 自动分类不在本次范围。 +- 操作对象在打开面板时冻结为去重后的素材 ID 列表,不随后续选择变化扩散;项目切换关闭面板并忽略迟到响应。混合选择含虚拟版本、未登记附件或已删除资源时,不允许只对其中一部分静默保存,入口禁用并给出原因。 +- 批量范围为当前入口展示的完整选中集合:资源面板保留跨筛选的选择时,也必须显示实际目标数量;不得仅修改第一项或仅修改当前可见项。每批最多 200 个不同素材,超过上限要求缩小选择。 +- 原生新增局部命令 `add_local_project_resource_tags`,参数 `input: { projectPath, expectedProjectId, expectedProjectRevision, assetIds, tags }`;仅消费当前 manifest 素材 ID。响应 `{ assets, committedProjectRevision }` 返回整组选中素材最新条目。沿用 `asset.register` 权限、项目身份、写锁及 revision CAS;不新建 manifest schema、数据库字段或外部 API。 +- 原生按锁内最新 manifest 为每项合并标签,沿用现有单标签长度与标签总量限制,先校验全部目标及合并后上限,再一次写 manifest;非法 ID、标签、项目身份或版本冲突不写入任何一项。重复标签重试不重复追加;整批无变化时不推进 revision、不追加修改审计。已写 manifest 后的审计或 revision 异常必须明确返回“已写入”状态信息,沿用既有单资源写入错误语义,不能谎称回滚。 +- 保存期间禁用重复提交及关闭;失败保留待追加标签,CAS 冲突需明确报错而非自动覆盖。成功用原生返回条目刷新宿主 manifest 与筛选统计,不循环调用单素材保存、不按旧闭包覆盖最新其他素材。 +- 单素材标签编辑仍保持既有增删标签行为;菜单信息/类型入口、批量移动、文档与引擎资源预览不得回退。验收覆盖不同原始标签与分类、资源面板/画布多选入口、重复标签、超限及混合选择、批次失败零部分写、一次 revision、项目切换迟到回包和单素材回归。 +- 批量模式的标签输入仅显示待追加草稿,不把各素材已有标签并集作为提交值。资源面板入口放入现有动作行,画布入口沿用选中工具栏的收纳。响应条目按去重后请求 ID 的首次出现顺序返回,无变化时返回当前 revision;DTO 使用 camelCase 并拒绝未知字段,批次上限按去重后数量计算。 + ## 资源画布生成、展示与布局合同 - 生成工具点击后先在当前栏目创建临时占位卡,并以卡片为锚点展示独立生成浮层;占位不登记为正式素材、不进入 Agent 可引用资源集。上传仍沿用文件选择,不创建虚假生成任务。关闭编辑浮层不应丢失正在执行的任务;切换项目不得将旧项目结果或草稿写入新项目。 From 195b7dd5dd143375c9b6700855ef1d39df9c101c Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 21:37:23 +0800 Subject: [PATCH 51/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=A0=E5=9B=BE401?= =?UTF-8?q?=20404=E9=97=AE=E9=A2=98=20(#394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/394 Co-authored-by: Linghong Co-committed-by: Linghong --- .../agc-skills/agc-client-projection/SKILL.md | 2 +- .../references/projection-contract.md | 4 +- .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 177 ++--- .../src-tauri/src/project/resource_editor.rs | 297 +++++-- .../background_removal_tests.rs | 725 ++++++++++++++++++ .../src/view/project-development/index.tsx | 19 +- .../tests/appSurface/home.suite.ts | 8 +- .../appSurface/project-development.suite.ts | 12 +- .../tests/directActiveTurns.test.tsx | 11 +- .../projectResourceLiveIntegration.test.tsx | 42 +- .../tests/resourceVersionReplacement.test.tsx | 68 +- .../shared-memory/decision-log.md | 20 +- docs/project-memory/shared-memory/pitfalls.md | 4 + ...方案】AGC抠图模式与背景色透传-2026-09-16.md | 24 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 +- .../crates/api-server/src/editor_project.rs | 4 +- .../ImageCanvasWorldView.test.tsx | 5 +- 18 files changed, 1216 insertions(+), 215 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index f25ab993d..5614ef9d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result 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. 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. +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, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known. 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. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 9129efc1a..7374d1149 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -16,4 +16,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `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. +`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 request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details. + +After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6debfefb3..26790f360 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.20", + "version": "2026-08-26.23", "skills": [ { "name": "agc-game-production-workflow", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe" + "sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index f31a3cdd4..9984ff4d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1294,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .map(|asset| bridge_registered_resource(asset, include_sequence_frames)) .collect::>(); let next_offset = (offset + resources.len() < total).then_some(offset + resources.len()); - let pending = list_pending_local_project_resource_edits_at( + let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount) + .then(current_platform_session) + .flatten(); + let pending = list_pending_local_project_resource_edits_for_session_at( ListPendingLocalProjectResourceEditsInput { project_path: root .to_str() @@ -1302,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .to_string(), expected_project_id: manifest.project_id, }, + platform_session.as_ref(), )? .into_iter() .map(|edit| { @@ -1311,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { "mode": edit.generation_mode, "sourceResourceId": edit.source_resource_id, "assetName": edit.asset_name, + "backgroundMode": edit.background_mode, + "screenColor": edit.screen_color, "phase": edit.phase, "createdAt": edit.created_at, }) @@ -1801,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments: fn bridge_completed_resource_result( root: &Path, - kind: DirectResourceGenerationKind, - mode: DirectResourceGenerationMode, + kind: &str, + mode: &str, result: DeriveLocalProjectResourceResult, ) -> Result { let asset = result @@ -1814,8 +1820,8 @@ fn bridge_completed_resource_result( Ok(json!({ "status": "completed", "operationId": result.operation_id, - "kind": kind.as_str(), - "mode": mode.as_str(), + "kind": kind, + "mode": mode, "sourceResourceId": result.source_resource_id, "committedProjectRevision": result.committed_project_revision, "resource": bridge_registered_resource(asset, true), @@ -1910,10 +1916,17 @@ async fn bridge_create_or_derive_resource( source_version_id: None, prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: None, + screen_color: None, }; with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await? }; - bridge_completed_resource_result(&state.root, input.kind, input.mode, completed) + bridge_completed_resource_result( + &state.root, + input.kind.as_str(), + input.mode.as_str(), + completed, + ) } .await; match result { @@ -1927,7 +1940,8 @@ async fn bridge_create_or_derive_resource( } async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { - let result = async { + let _generation_guard = state.resource_generation_gate.lock().await; + let result = with_direct_editor_api_credentials(async { 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")?; @@ -1948,74 +1962,72 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val if !source_asset.media_type.starts_with("image/") { return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); } - let source_resource_id = source_asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty() && !value.starts_with("local-asset:")) - .ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())? - .to_string(); - let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?; - let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?; - let client = crate::http_client::agc_main_site_client_builder() - .build() - .map_err(|_| "创建抠图服务连接失败".to_string())?; - let context = - prepare_external_canvas_generation_context(&state.root, &client, &access).await?; + let background_mode = background_mode.unwrap_or("complex").to_string(); + let source_resource_id = bridge_asset_canonical_resource_id(source_asset); let fingerprint = background_removal_request_fingerprint( &source_asset_id, &asset_name, - background_mode, + Some(background_mode.as_str()), 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"); + let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + }, + platform_session.as_ref(), + )?; + let matching_pending = pending + .into_iter() + .filter(|pending| { + pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval + && (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str()) + || pending.source_resource_id == format!("local-asset:{source_asset_id}")) + && pending.asset_name == asset_name + && pending.background_mode.as_deref().unwrap_or("complex") + == background_mode.as_str() + && pending.screen_color.as_deref() == screen_color + }) + .collect::>(); + if matching_pending.len() > 1 { + return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string()); } - 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(&request_body), - ) - .send() - .await - .map_err(|error| format!("抠图服务提交失败:{error}"))?; - let status = response.status(); - let payload = response - .json::() - .await - .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; - if !status.is_success() { - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string()); - } - return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); - } - let queue_state = external_editor_response_data(&payload).clone(); - Ok::<_, String>(json!({ - "status": "queued", - "sourceLocalAssetId": source_asset_id, - "assetName": asset_name, - "projectId": manifest.project_id, - "assetFolderId": context.asset_folder_id, - "queueState": bridge_safe_queue_state(queue_state), - })) - } + let completed = if let Some(pending) = matching_pending.into_iter().next() { + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + operation_id: pending.operation_id, + }) + .await? + } else { + let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; + let request = DeriveLocalProjectResourceInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + expected_project_revision: revision, + operation_id, + idempotency_key, + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id, + source_asset_id: Some(source_asset_id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: asset_name.clone(), + background_mode: Some(background_mode), + screen_color: screen_color.map(str::to_string), + }; + derive_local_project_resource_at(request).await? + }; + emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); + bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) + }) .await; match result { Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false), @@ -2041,17 +2053,6 @@ fn background_removal_request_fingerprint( } } -fn bridge_safe_queue_state(value: Value) -> Value { - let object = value.as_object(); - json!({ - "operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str), - "status": object.and_then(|value| value.get("status")).and_then(Value::as_str), - "phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str), - "progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64), - "updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64), - }) -} - fn bridge_art_resources( root: &Path, asset_paths: &[String], @@ -3844,20 +3845,4 @@ mod tests { ); } } - - #[test] - fn bridge_background_removal_queue_projection_is_bounded() { - let projection = bridge_safe_queue_state(json!({ - "operationId": "background-removal-1", - "status": "queued", - "phaseLabel": "排队中", - "progress": 0, - "updatedAtMicros": 1, - "error": "private provider detail", - "signedUrl": "https://private.invalid/result" - })); - assert_eq!(projection["operationId"], "background-removal-1"); - assert!(projection.get("error").is_none()); - assert!(projection.get("signedUrl").is_none()); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 46bb81c24..145330f56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -113,6 +113,7 @@ fn resource_edit_project_mutation_lock(root: &Path) -> Result &'static str { match self { Self::ImageReference => "image-reference", + Self::BackgroundRemoval => "background-removal", Self::Svg => "svg", Self::CharacterAnimation => "character-animation", Self::Video => "video", @@ -188,6 +191,10 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_version_id: Option, pub(crate) prompt: String, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -220,6 +227,10 @@ pub(crate) struct PendingLocalProjectResourceEdit { pub(crate) source_resource_id: String, pub(crate) source_asset_id: Option, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, pub(crate) prompt_sha256: String, pub(crate) phase: String, pub(crate) created_at: u64, @@ -385,6 +396,10 @@ struct ResourceEditLedger { prompt: String, asset_name: String, #[serde(default)] + background_mode: Option, + #[serde(default)] + screen_color: Option, + #[serde(default)] provider_request_issued_at: Option, #[serde(default)] access_scheme: Option, @@ -829,7 +844,7 @@ fn resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "operationId": input.operation_id, @@ -840,8 +855,13 @@ fn resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -851,7 +871,7 @@ fn legacy_resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "expectedProjectRevision": input.expected_project_revision, @@ -862,8 +882,13 @@ fn legacy_resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -1106,9 +1131,21 @@ fn infer_resource_edit_source_media_type( .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase); let media_type = match (edit_kind, extension.as_deref()) { - (LocalProjectResourceEditKind::ImageReference, Some("png")) => "image/png", - (LocalProjectResourceEditKind::ImageReference, Some("jpg" | "jpeg")) => "image/jpeg", - (LocalProjectResourceEditKind::ImageReference, Some("webp")) => "image/webp", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("png"), + ) => "image/png", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("jpg" | "jpeg"), + ) => "image/jpeg", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("webp"), + ) => "image/webp", (LocalProjectResourceEditKind::Svg, Some("svg")) => "image/svg+xml", (LocalProjectResourceEditKind::Video, Some("mp4")) => "video/mp4", (LocalProjectResourceEditKind::Video, Some("webm")) => "video/webm", @@ -1145,7 +1182,8 @@ fn infer_resource_edit_source_media_type( fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { match edit_kind { - LocalProjectResourceEditKind::ImageReference => "art-image", + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => "art-image", LocalProjectResourceEditKind::Svg => "svg", LocalProjectResourceEditKind::CharacterAnimation => "character-animation", LocalProjectResourceEditKind::Video => "video", @@ -1373,8 +1411,11 @@ fn resolve_resource_edit_source( { return Err("音频编辑只能用于音频资源".to_string()); } - LocalProjectResourceEditKind::ImageReference if !lower_media_type.starts_with("image/") => { - return Err("图片参考编辑只能用于图片资源".to_string()); + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + if !lower_media_type.starts_with("image/") => + { + return Err("此操作只能用于图片资源".to_string()); } _ => {} } @@ -2023,8 +2064,11 @@ fn validate_resource_edit_source_recovery_state( || (ledger.source_remote_asset_object_id.is_some() && !ledger.source_upload_completed) || (ledger.source_remote_resource_id.is_some() && ledger.source_remote_asset_object_id.is_none()) - || (input.edit_kind != LocalProjectResourceEditKind::ImageReference - && ledger.source_remote_resource_id.is_some()) + || (!matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.source_remote_resource_id.is_some()) { return Err("result-unknown: 源资源远端恢复阶段不完整,禁止自动重放".to_string()); } @@ -2081,7 +2125,8 @@ async fn ensure_resource_edit_source_reference( &source_identity, )? { let stable_reference = match input.edit_kind { - LocalProjectResourceEditKind::ImageReference => binding + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => binding .remote_resource_id .filter(|reference| is_registered_editor_reference_id(reference)), LocalProjectResourceEditKind::Video @@ -2188,45 +2233,48 @@ async fn ensure_resource_edit_source_reference( confirmed.value }; - let (stable_reference, remote_resource_id, width, height) = - if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - let bytes = source - .bytes - .as_deref() - .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; - let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { - resource_id - } else { - let registered = register_resource_edit_source_image( - client, - access, - source, - local_asset_id, - &binding_key, - &canvas_context.project_id, - &object_key, - &asset_object_id, - ) - .await?; - let resource_id = registered.value.0; - ledger.source_remote_resource_id = Some(resource_id.clone()); - write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; - if let Some(error) = registered.post_response_session_error { - return Err(error); - } - resource_id - }; - ( - resource_id.clone(), - Some(resource_id), - Some(decoded.width()), - Some(decoded.height()), - ) + let (stable_reference, remote_resource_id, width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { + resource_id } else { - (object_key.clone(), None, None, None) + let registered = register_resource_edit_source_image( + client, + access, + source, + local_asset_id, + &binding_key, + &canvas_context.project_id, + &object_key, + &asset_object_id, + ) + .await?; + let resource_id = registered.value.0; + ledger.source_remote_resource_id = Some(resource_id.clone()); + write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; + if let Some(error) = registered.post_response_session_error { + return Err(error); + } + resource_id }; + ( + resource_id.clone(), + Some(resource_id), + Some(decoded.width()), + Some(decoded.height()), + ) + } else { + (object_key.clone(), None, None, None) + }; access.validate_frozen_session()?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -2330,6 +2378,28 @@ fn resource_edit_remote_request( "generationInputs": generation_inputs, }), )), + LocalProjectResourceEditKind::BackgroundRemoval => { + let background_mode = input.background_mode.as_deref().unwrap_or("complex"); + validate_background_removal_options(background_mode, input.screen_color.as_deref())?; + let mut body = serde_json::json!({ + "sourceImageSrc": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "sourceResourceId": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "assetKind": source.asset_kind, + "assetLabel": asset_name, + "backgroundMode": background_mode, + "generationInputs": generation_inputs, + }); + if let Some(screen_color) = input.screen_color.as_deref() { + body["screenColor"] = serde_json::json!(screen_color); + } + if let Some(context) = canvas_context { + body["projectId"] = serde_json::json!(context.project_id); + body["assetFolderId"] = serde_json::json!(context.asset_folder_id); + } + Ok(("/api/external/v1/editor/images/background-removals", body)) + } LocalProjectResourceEditKind::CharacterAnimation => { let mut body = serde_json::json!({ "sourceLayerId": format!("resource-{}", input.operation_id), @@ -2436,6 +2506,31 @@ fn resource_edit_remote_request( } } +fn validate_background_removal_options( + background_mode: &str, + screen_color: Option<&str>, +) -> Result<(), String> { + if !matches!(background_mode, "complex" | "flat") { + return Err("抠图 backgroundMode 必须是 complex 或 flat".to_string()); + } + if background_mode == "complex" && screen_color.is_some() { + return Err("complex 抠图不能携带 screenColor".to_string()); + } + if let Some(screen_color) = screen_color { + let valid_hex = screen_color.len() == 7 + && screen_color.starts_with('#') + && screen_color.as_bytes()[1..].iter().all(|byte| { + byte.is_ascii_digit() + || (b'a'..=b'f').contains(byte) + || (b'A'..=b'F').contains(byte) + }); + if screen_color != "auto" && !valid_hex { + return Err("flat 抠图 screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + } + Ok(()) +} + fn resource_edit_operation_id(payload: &serde_json::Value) -> Option { let data = external_editor_response_data(payload); json_string_field(data, "operationId").or_else(|| { @@ -2464,11 +2559,13 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, "/api/editor/images/edits" + | "/api/editor/images/background-removals" | "/api/editor/character-animations/generations" | "/api/editor/videos/generations" | "/api/editor/audios/sound-effects/generations" | "/api/editor/audios/background-music/generations" | "/api/external/v1/editor/images/edits" + | "/api/external/v1/editor/images/background-removals" | "/api/external/v1/editor/character-animations/generations" | "/api/external/v1/editor/videos/generations" | "/api/external/v1/editor/audios/sound-effects/generations" @@ -2954,6 +3051,17 @@ fn validate_downloaded_media( let is_jpeg = starts(&[0xff, 0xd8, 0xff]); let is_webp = bytes.len() >= 12 && starts(b"RIFF") && &bytes[8..12] == b"WEBP"; match edit_kind { + LocalProjectResourceEditKind::BackgroundRemoval => { + if !is_png { + return Err("抠图结果必须是带透明通道的 PNG".to_string()); + } + let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) + .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + if !decoded.color().has_alpha() { + return Err("抠图结果缺少透明通道".to_string()); + } + Ok(("image/png".to_string(), "png".to_string())) + } LocalProjectResourceEditKind::ImageReference => { if is_png { Ok(("image/png".to_string(), "png".to_string())) @@ -3246,6 +3354,7 @@ async fn prepare_remote_resource_edit( let prepared_source = if matches!( input.edit_kind, LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::CharacterAnimation ) || (input.edit_kind == LocalProjectResourceEditKind::Video && input.generation_mode == LocalProjectResourceGenerationMode::Derive) @@ -3275,6 +3384,7 @@ async fn prepare_remote_resource_edit( } else if matches!( input.edit_kind, LocalProjectResourceEditKind::CharacterAnimation + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::Video | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic @@ -4444,8 +4554,11 @@ fn write_resource_edit_result_binding( let Some(asset_object_id) = ledger.remote_asset_object_id.as_deref() else { return Ok(()); }; - if input.edit_kind == LocalProjectResourceEditKind::ImageReference - && ledger.remote_resource_id.is_none() + if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.remote_resource_id.is_none() { return Ok(()); } @@ -4455,7 +4568,11 @@ fn write_resource_edit_result_binding( media_read_limit(&input.edit_kind), "派生资源 binding 文件", )?; - let (width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + let (width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { let decoded = image::load_from_memory(&bytes) .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; (Some(decoded.width()), Some(decoded.height())) @@ -4775,6 +4892,17 @@ fn commit_resource_edit_version( pub(crate) fn list_pending_local_project_resource_edits_at( input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let current_platform_session = current_platform_session(); + list_pending_local_project_resource_edits_for_session_at( + input, + current_platform_session.as_ref(), + ) +} + +pub(crate) fn list_pending_local_project_resource_edits_for_session_at( + input: ListPendingLocalProjectResourceEditsInput, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result, String> { let root = Path::new(input.project_path.trim()); validate_project_root(root)?; @@ -4782,8 +4910,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let current_platform_session = current_platform_session(); - let _platform_session_lease = current_platform_session + let _platform_session_lease = platform_session .as_ref() .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; @@ -4825,10 +4952,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if !matches!( ledger.phase, ResourceEditLedgerPhase::Committed | ResourceEditLedgerPhase::Archived - ) && resource_edit_pending_is_visible_to_current_principal( - &ledger, - current_platform_session.as_ref(), - ) { + ) && resource_edit_pending_is_visible_to_current_principal(&ledger, platform_session) + { pending.push(PendingLocalProjectResourceEdit { operation_id: ledger.operation_id, edit_kind: ledger.edit_kind, @@ -4836,6 +4961,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( source_resource_id: ledger.source_resource_id, source_asset_id: ledger.source_asset_id, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, prompt_sha256: sha256_hex(ledger.prompt.as_bytes()), phase: ledger.phase.as_str().to_string(), created_at: ledger.created_at, @@ -5186,6 +5313,8 @@ pub(crate) async fn resume_local_project_resource_edit_at( source_version_id, prompt: ledger.prompt, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, }) .await } @@ -5195,6 +5324,12 @@ pub(crate) async fn derive_local_project_resource_at( ) -> Result { validate_resource_edit_uuid(&input.operation_id, "operationId")?; validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey")?; + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + validate_background_removal_options( + input.background_mode.as_deref().unwrap_or("complex"), + input.screen_color.as_deref(), + )?; + } if input.expected_project_revision > 9_007_199_254_740_991 { return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); } @@ -5281,6 +5416,8 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: None, api_identity_scheme: None, @@ -5484,6 +5621,9 @@ pub(crate) async fn derive_local_project_resource_at( Ok(result) } +#[cfg(test)] +mod background_removal_tests; + #[cfg(test)] mod tests { use super::*; @@ -5963,6 +6103,8 @@ mod tests { source_version_id: None, prompt: "保留原意并补充红发角色设定".to_string(), asset_name: "规则编辑版".to_string(), + background_mode: None, + screen_color: None, } } @@ -6000,6 +6142,8 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: input .edit_kind @@ -8539,6 +8683,37 @@ mod tests { serde_json::json!("stable-image-reference") ); assert!(image_body.get("sourceImageSrc").is_none()); + let mut background = image; + background.edit_kind = LocalProjectResourceEditKind::BackgroundRemoval; + background.background_mode = Some("flat".to_string()); + background.screen_color = Some("auto".to_string()); + let (background_endpoint, background_body) = resource_edit_remote_request( + &background, + &ResourceEditSourceSnapshot { + media_type: "image/png".to_string(), + asset_kind: "art-image".to_string(), + source_path: Some("assets/source.png".to_string()), + bytes: Some(resource_editor_test_png()), + ..source.clone() + }, + "去除背景", + "透明图", + Some("stable-image-reference"), + Some(&ExternalCanvasGenerationContext { + project_id: "project-1".to_string(), + asset_folder_id: "folder-1".to_string(), + canvas_name: "测试画板".to_string(), + }), + ) + .expect("build background removal request"); + assert_eq!( + background_endpoint, + "/api/external/v1/editor/images/background-removals" + ); + assert_eq!(background_body["backgroundMode"], "flat"); + assert_eq!(background_body["screenColor"], "auto"); + assert_eq!(background_body["projectId"], "project-1"); + assert_eq!(background_body["assetFolderId"], "folder-1"); assert!(is_external_resource_edit_endpoint( "/api/editor/videos/generations" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs new file mode 100644 index 000000000..aa731aeef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -0,0 +1,725 @@ +use super::*; + +use std::io::{Cursor, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +const TEST_PROJECT_ID: &str = "background-removal-project"; +const TEST_API_KEY: &str = "background-removal-key"; +const REMOTE_PROJECT_ID: &str = "background-removal-remote-project"; +const REMOTE_FOLDER_ID: &str = "background-removal-folder"; +const SOURCE_RESOURCE_ID: &str = "editor-resource-background-removal-source"; + +struct BackgroundRemovalFixture { + directory: tempfile::TempDir, + request: DeriveLocalProjectResourceInput, + source_asset_id: String, + source_bytes: Vec, +} + +#[tokio::test] +async fn background_removal_platform_account_uses_runtime_job_and_platform_read_url_routes() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind platform account server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("platform address") + ); + let _session_guard = install_test_platform_session( + "background-removal-owner", + "background-removal-platform-token", + &base_url, + ); + let session = current_platform_session().expect("platform account session"); + let fixture = background_removal_fixture_for_session(&base_url, Some(&session)); + let generated_png = test_png(); + let signed_url = format!("{base_url}/platform-result.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..6 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture requests") + .push(request.clone()); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + let line = request.lines().next().unwrap_or_default(); + if index != 5 { + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-platform-token")); + } + match index { + 2 => { + assert!(line.starts_with("POST /api/editor/images/background-removals ")); + assert_submission(&request); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"queueState": { + "operationId": "platform-background-removal", "status": "queued", "pollAfterMs": 0 + }}}), + ); + } + 3 => { + assert!(line.starts_with( + "GET /api/runtime/external-generation/jobs/platform-background-removal " + )); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"job": { + "operationId": "platform-background-removal", "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-platform-background-removal", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/platform-background-removal.png", + "assetObjectId": "platform-background-removal-object" + }} + }}}), + ); + } + 4 => { + assert!(line.starts_with("GET /api/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 5 => { + assert!(line.starts_with("GET /platform-result.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = derive_local_project_resource_at(fixture.request.clone()) + .await + .expect("complete platform account background removal"); + server.join().expect("join platform account server"); + let requests = captured.lock().expect("read platform requests"); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/editor/images/background-removals ")) + .count(), + 1 + ); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("platform asset").local_path) + ) + .expect("read platform result"), + generated_png + ); +} + +impl BackgroundRemovalFixture { + fn root(&self) -> &Path { + self.directory.path() + } +} + +fn test_png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 2) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode test PNG"); + output.into_inner() +} + +#[test] +fn background_removal_download_rejects_non_png_and_png_without_alpha() { + let malformed = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + b"not-a-png", + ) + .expect_err("background removal must reject non-PNG bytes"); + assert!(malformed.contains("PNG")); + + let mut rgb_png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image::RgbImage::new(2, 2)) + .write_to(&mut rgb_png, image::ImageFormat::Png) + .expect("encode RGB PNG"); + let missing_alpha = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + rgb_png.get_ref(), + ) + .expect_err("background removal must reject PNG without alpha channel"); + assert!(missing_alpha.contains("透明通道")); +} + +fn background_removal_fixture(base_url: &str) -> BackgroundRemovalFixture { + background_removal_fixture_for_session(base_url, None) +} + +fn background_removal_fixture_for_session( + base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> BackgroundRemovalFixture { + let directory = tempfile::tempdir().expect("create background removal fixture"); + let root = directory.path(); + init_local_game_project_at(root, TEST_PROJECT_ID, "抠图账本闭环测试") + .expect("initialize local project"); + let source_bytes = test_png(); + let uploaded = upload_local_asset_at(root, "source.png", "image/png", &source_bytes) + .expect("register source image"); + let manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .expect("find source asset") + .clone(); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read source project revision") + .revision; + let request = DeriveLocalProjectResourceInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + expected_project_revision: revision, + operation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id: format!("local-asset:{}", source_asset.id), + source_asset_id: Some(source_asset.id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: "透明角色".to_string(), + background_mode: Some("flat".to_string()), + screen_color: Some("auto".to_string()), + }; + let source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve background removal source"); + let bearer_token = platform_session + .map(|session| session.access_token.as_str()) + .unwrap_or(TEST_API_KEY); + let access = ExternalEditorBindingAccess::new(base_url, bearer_token, platform_session) + .expect("create developer access"); + let principal = + external_editor_binding_principal(&access).expect("resolve developer principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + REMOTE_FOLDER_ID, + unix_timestamp(), + ) + .expect("build project binding"), + ) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("build source identity"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + &source_identity, + Some(SOURCE_RESOURCE_ID), + "registered/source.png", + "registered-source-object", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("build source binding"), + ) + .expect("write source binding"); + BackgroundRemovalFixture { + directory, + request, + source_asset_id: source_asset.id, + source_bytes, + } +} + +async fn with_test_credentials( + base_url: &str, + operation: impl std::future::Future, +) -> T { + let _platform_session_guard = clear_test_platform_session(); + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.to_string(), + TEST_API_KEY.to_string(), + ), + operation, + ) + .await +} + +fn accept_request(listener: &TcpListener, index: usize) -> TcpStream { + listener + .set_nonblocking(true) + .expect("set background removal listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore background removal stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for background removal request {index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept background removal request {index}: {error}"), + } + } +} + +fn read_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set request read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read HTTP request"); + assert!(read > 0, "request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read HTTP request body"); + assert!(read > 0, "request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn write_json(stream: &mut TcpStream, status: &str, value: serde_json::Value) { + let body = value.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("write JSON response"); +} + +fn write_bytes(stream: &mut TcpStream, media_type: &str, bytes: &[u8]) { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + ) + .expect("write media headers"); + stream.write_all(bytes).expect("write media body"); +} + +fn completed_status(operation_id: &str) -> serde_json::Value { + serde_json::json!({"data": { + "operationId": operation_id, + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-background-removal-result", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/background-removal.png", + "assetObjectId": "background-removal-result-object" + }} + }}) +} + +fn assert_submission(request: &str) { + assert!( + request.starts_with("POST /api/editor/images/background-removals ") + || request.starts_with("POST /api/external/v1/editor/images/background-removals ") + ); + let lower = request.to_ascii_lowercase(); + assert!(lower.contains("idempotency-key:")); + let body = request.split("\r\n\r\n").nth(1).expect("submission body"); + let payload: serde_json::Value = serde_json::from_str(body).expect("parse submission body"); + assert_eq!(payload["sourceImageSrc"], SOURCE_RESOURCE_ID); + assert_eq!(payload["sourceResourceId"], SOURCE_RESOURCE_ID); + assert_eq!(payload["projectId"], REMOTE_PROJECT_ID); + assert_eq!(payload["assetFolderId"], REMOTE_FOLDER_ID); + assert_eq!(payload["backgroundMode"], "flat"); + assert_eq!(payload["screenColor"], "auto"); +} + +fn assert_developer_submission(request: &str) { + assert!(request.starts_with("POST /api/external/v1/editor/images/background-removals ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-key")); + assert_submission(request); +} + +fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool { + let line = request.lines().next().unwrap_or_default(); + if line.starts_with("GET /api/external/v1/editor/projects ") + || line.starts_with("GET /api/editor/projects ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}), + ); + true + } else if line.starts_with("GET /api/external/v1/editor/assets/library ") + || line.starts_with("GET /api/editor/assets/library ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}), + ); + true + } else { + false + } +} + +#[tokio::test] +async fn background_removal_derives_through_poll_download_and_manifest_commit() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind background removal server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/generated.png"); + let server_png = generated_png.clone(); + let server_signed_url = signed_url.clone(); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&requests); + let server = std::thread::spawn(move || { + for index in 0..8 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_requests + .lock() + .expect("capture requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "background-removal-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "queued", "pollAfterMs": 0 + }}), + ), + 4 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "running", "pollAfterMs": 0 + }}), + ), + 5 => write_json( + &mut stream, + "200 OK", + completed_status("background-removal-operation"), + ), + 6 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": { + "signedUrl": server_signed_url + }}}), + ); + } + 7 => { + assert!(line.starts_with("GET /generated.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect("complete background removal"); + server + .join() + .expect("join complete background removal server"); + let captured = requests.lock().expect("read captured requests"); + assert_eq!(captured.len(), 8); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + let derived = result.asset.expect("derived background removal asset"); + assert_ne!(derived.id, fixture.source_asset_id); + assert_eq!( + fs::read(fixture.root().join(&derived.local_path)).expect("read result"), + generated_png + ); + assert_eq!( + fs::read( + fixture.root().join( + result + .manifest + .assets + .iter() + .find(|asset| asset.id == fixture.source_asset_id) + .expect("source preserved") + .local_path + .clone() + ) + ) + .expect("read source"), + fixture.source_bytes + ); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read ledger") + .expect("ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert_eq!( + ledger.remote_canvas_project_id.as_deref(), + Some(REMOTE_PROJECT_ID) + ); +} + +#[tokio::test] +async fn background_removal_resumes_accepted_operation_without_reposting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind resume server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/resumed.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..7 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture resume requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "resume-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "500 Internal Server Error", + serde_json::json!({"error": "interrupted"}), + ), + 4 => write_json( + &mut stream, + "200 OK", + completed_status("resume-background-removal"), + ), + 5 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 6 => { + assert!(line.starts_with("GET /resumed.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let first_error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("first polling attempt is interrupted"); + assert!(first_error.contains("result-unknown")); + let accepted = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read accepted ledger") + .expect("accepted ledger exists"); + assert_eq!(accepted.phase, ResourceEditLedgerPhase::Accepted); + assert_eq!( + accepted.remote_operation_id.as_deref(), + Some("resume-background-removal") + ); + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list developer pending operations"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].operation_id, fixture.request.operation_id); + assert_eq!(pending[0].background_mode.as_deref(), Some("flat")); + assert_eq!(pending[0].screen_color.as_deref(), Some("auto")); + let result = with_test_credentials( + &base_url, + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + operation_id: fixture.request.operation_id.clone(), + }), + ) + .await + .expect("resume accepted background removal"); + server.join().expect("join resume server"); + let captured = captured.lock().expect("read resume requests"); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + assert_eq!(result.manifest.assets.len(), 2); + assert!(list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list pending after resume") + .is_empty()); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("resumed asset").local_path) + ) + .expect("read resumed result"), + generated_png + ); +} + +#[tokio::test] +async fn background_removal_remote_failure_keeps_manifest_without_result() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failure server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let server = std::thread::spawn(move || { + for index in 0..4 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + if index == 2 { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } else { + assert!(request + .starts_with("GET /api/external/v1/generations/failed-background-removal ")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "failed" + }}), + ); + } + } + }); + + let error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("remote failure must be returned"); + server.join().expect("join failure server"); + assert!(error.contains("remote-terminal-failed")); + let manifest = + read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].id, fixture.source_asset_id); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read failed ledger") + .expect("failed ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::RemoteFailed); + assert!(ledger.result_asset_id.is_none()); + assert!( + read_optional_resource_edit_staging(fixture.root(), &fixture.request.operation_id) + .expect("read absent staging") + .is_none() + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 1bd650976..184007760 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -44,6 +44,7 @@ import { Replace, RotateCcw, Search, + Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -427,6 +428,8 @@ type PendingLocalProjectResourceEdit = { editKind: string; sourceResourceId: string; assetName: string; + backgroundMode?: string | null; + screenColor?: string | null; phase: string; createdAt: number; }; @@ -467,8 +470,20 @@ type ResourceEditServiceIdentityConfirmation = { expiresAt: number; }; -function pendingResourceEditKindLabel(editKind: string) { +function pendingResourceEditKindLabel({ + editKind, + backgroundMode, + screenColor, +}: PendingLocalProjectResourceEdit) { if (editKind === 'image') return '图片编辑'; + if (editKind === 'background-removal') { + if (backgroundMode === 'complex') return '图片抠图 · 复杂背景'; + if (backgroundMode === 'flat') { + const color = screenColor === 'auto' ? '自动背景色' : screenColor; + return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`; + } + return '图片抠图'; + } if (editKind === 'text') return '文本编辑'; if (editKind === 'agent-result') return '智能体结果编辑'; return '资源编辑'; @@ -9268,7 +9283,7 @@ export default function ProjectDevelopmentView({
{pending.assetName} - {pendingResourceEditKindLabel(pending.editKind)} ·{' '} + {pendingResourceEditKindLabel(pending)} ·{' '} {pendingResourceEditCreatedAtLabel(pending.createdAt)} diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a31d69f89..475242de0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -411,9 +411,7 @@ export function registerClientHomeTests() { await openResourceBookCategory('UI 交互'); expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await findResourceSelectButton('版本 1'), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -582,9 +580,7 @@ export function registerClientHomeTests() { }), ).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await findResourceSelectButton('版本 1'), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ae09f6798..18bda4813 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3836,7 +3836,9 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' }); + const infoButton = await screen.findByRole('button', { + name: '查看hero.png资源信息', + }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 @@ -4096,13 +4098,7 @@ export function registerProjectWorkbenchFoundationTests() { within(audioToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual([ - '引用资源 bgm.mp3', - '编辑标签', - '重命名', - '导出', - '删除素材', - ]); + ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']); // 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制, // 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了 diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index f7acdba4e..043918975 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -51,9 +51,11 @@ describe('useDirectActiveTurns', () => { }); it('clears to a stable empty snapshot when the hook is disabled', async () => { - const invoke = vi.fn( - async () => [] as GameCreatorDirectActiveTurn[], - ) as never; + let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const invoke = vi.fn(() => snapshot) as never; const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }), @@ -62,7 +64,8 @@ describe('useDirectActiveTurns', () => { const emptySnapshot = result.current.activeTurns; await act(async () => { - await result.current.refreshActiveTurns(); + resolveSnapshot([]); + await snapshot; }); expect(result.current.activeTurns).toBe(emptySnapshot); rerender({ enabled: false }); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 4234d82b8..ef129cd12 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -109,6 +109,8 @@ type PendingResourceEditFixture = { editKind: string; sourceResourceId: string; assetName: string; + backgroundMode?: string | null; + screenColor?: string | null; phase: string; createdAt: number; }; @@ -1294,6 +1296,42 @@ describe('project resource live canvas integration', () => { }); }); + it('恢复面板显示同名抠图的模式和颜色,缺失字段不推断默认值', async () => { + installTauri({ + pendingResourceEdits: [ + { backgroundMode: 'complex' }, + { backgroundMode: 'flat', screenColor: 'auto' }, + { backgroundMode: 'flat', screenColor: '#AABBCC' }, + { backgroundMode: 'flat' }, + {}, + ].map((options, index) => ({ + operationId: `background-${index}`, + editKind: 'background-removal', + sourceResourceId: 'source-art', + assetName: '透明底', + phase: 'accepted', + createdAt: 1, + ...options, + })), + }); + render(); + fireEvent.click( + await screen.findByRole('button', { name: '管理未完成编辑 (5)' }), + ); + const labels = screen + .getAllByText('透明底') + .map((name) => name.nextElementSibling?.textContent); + expect( + labels.map((label) => label?.split(' · ').slice(0, -1).join(' · ')), + ).toEqual([ + '图片抠图 · 复杂背景', + '图片抠图 · 平面背景 · 自动背景色', + '图片抠图 · 平面背景 · #AABBCC', + '图片抠图 · 平面背景', + '图片抠图', + ]); + }); + it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => { const failedOperationId = '11111111-1111-4111-8111-111111111111'; const resumableOperationId = '22222222-2222-4222-8222-222222222222'; @@ -2072,7 +2110,9 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' })); + fireEvent.click( + screen.getByRole('button', { name: '查看hero.png资源信息' }), + ); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index cfbc324f9..e8f2b5e4b 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -379,7 +379,10 @@ function toolbarAction(toolbar: HTMLElement, name: string) { const visible = within(toolbar).queryByRole('button', { name }); if (visible) return visible; fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); - return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name }); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole( + 'button', + { name }, + ); } async function selectCardAndOpenToolbar(label: string) { @@ -550,12 +553,20 @@ describe('版本级资源替换', () => { const toolbar = await selectCardAndOpenToolbar('legacy.png'); fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); const menu = screen.getByRole('group', { name: '更多操作' }); - const viewport = () => document.querySelector('[data-resource-viewport]') - ?.getAttribute('data-resource-viewport'); + const viewport = () => + document + .querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); const before = viewport(); expect(before).toBeTruthy(); - const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }); - act(() => { menu.dispatchEvent(wheel); }); + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + }); + act(() => { + menu.dispatchEvent(wheel); + }); expect(wheel.defaultPrevented).toBe(false); expect(viewport()).toBe(before); @@ -568,9 +579,15 @@ describe('版本级资源替换', () => { const scene = document.querySelector('.game-resource-book-scene')!; act(() => { - scene.dispatchEvent(new WheelEvent('wheel', { - bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, - })); + scene.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 90, + clientY: 70, + }), + ); }); expect(viewport()).not.toBe(before); }); @@ -578,13 +595,17 @@ describe('版本级资源替换', () => { it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { renderReplacementWorkbench(); await selectCardAndOpenToolbar('legacy.png'); - const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' }); + const lateInfo = screen.getByRole('button', { + name: '查看late.png资源信息', + }); fireEvent.pointerDown(lateInfo, { button: 0 }); fireEvent.click(lateInfo); const panel = screen.getByRole('dialog', { name: '资源信息' }); expect(within(panel).getByText('late.png')).toBeTruthy(); expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); - fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' })); + fireEvent.click( + screen.getByRole('button', { name: '查看legacy.png资源信息' }), + ); const switched = screen.getByRole('dialog', { name: '资源信息' }); expect(within(switched).getByText('legacy.png')).toBeTruthy(); expect(within(switched).queryByText('late.png')).toBeNull(); @@ -597,10 +618,10 @@ describe('版本级资源替换', () => { // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); - fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' })); - expect( - screen.queryByRole('button', { name: '替换素材' }), - ).toBeNull(); + fireEvent.mouseEnter( + within(lateToolbar).getByRole('button', { name: '更多' }), + ); + expect(screen.queryByRole('button', { name: '替换素材' })).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), ).not.toBeNull(); @@ -614,9 +635,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); - expect( - toolbarAction(sourceToolbar, '替换素材'), - ).not.toBeNull(); + expect(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull(); }); it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => { @@ -895,9 +914,10 @@ describe('版本级资源替换', () => { const deleteButton = toolbarAction(toolbar, '删除素材'); const toolbarLabels = [ ...within(toolbar).getAllByRole('button'), - ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'), - ] - .map((button) => button.getAttribute('aria-label') ?? ''); + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole( + 'button', + ), + ].map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( toolbarLabels.indexOf('替换素材'), @@ -1411,9 +1431,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click( - toolbarAction(legacyToolbar, '替换素材'), - ); + fireEvent.click(toolbarAction(legacyToolbar, '替换素材')); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1431,9 +1449,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); - fireEvent.click( - toolbarAction(finalToolbar, '替换素材'), - ); + fireEvent.click(toolbarAction(finalToolbar, '替换素材')); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( within(dialog).getByRole('option', { name: '选择替换素材final.webp' }), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1b57435a0..0be34b555 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,13 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 + +## 2026-09-17 AGC 抠图提交使用远端画布项目身份 + +- 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId`;`assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。 +- 决策:抠图请求及工具回执统一使用 `prepare_external_canvas_generation_context` 返回的远端 `context.project_id`;本地 manifest 项目 ID 只用于绑定键和本地状态,不得作为主站业务请求的 `projectId`。 +- 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。 + ## 2026-09-17 图集切分模式改为显式声明 - 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 @@ -22,6 +29,7 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 - 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 + ## 2026-09-17 资源画布支持引擎资源只读预览 - 背景:Cocos Creator 工程里已有的引擎资源(模型、动画、预制体、材质、图集、压缩纹理…)此前在发现层就止步:`.glb` / `.prefab` / `.anim` / `.texture` 等扩展名既不可登记,也不进资源画布,工程导入后画布上只看得到位图、音频与脚本。 @@ -8211,8 +8219,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-24 AGC Direct 抠图语义工具 -- 决策:将 External v1 `/api/external/v1/editor/images/background-removals` 通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。 -- 约束:异步结果只投影有界队列状态,不允许模型自行构造源 URL 或在不确定提交后更换请求身份;External v1 负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。 +- 决策:将抠图能力通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;普通登录态使用账号鉴权的 `/api/editor/images/background-removals`,ExternalDeveloper 模式使用 External v1 `/api/external/v1/editor/images/background-removals`。客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。 +- 约束:异步结果与恢复语义以本文「2026-09-17 AGC 抠图接入本地资源编辑恢复闭环」决策为准。不允许模型自行构造源 URL 或在不确定提交后更换请求身份;两种路由都接收客户端稳定幂等身份,External v1 继续负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。 ## 2026-08-24 资源详情动作、空态滚动与最终图多步恢复 @@ -8865,6 +8873,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:限速后 `Genarrative-Full-Build-And-Deploy` #289 / #290 SUCCESS;采样期 Jenkins 峰值 10.2~10.5 核、限流不足 2s(可忽略),runner 峰值 12.07 核且持续出现 throttling,整机回落到 2.6%~19.8%。 - 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)。 +## 2026-09-17 AGC 抠图接入本地资源编辑恢复闭环 + +- 背景:`agc_remove_background` 原先只提交 `/api/editor/images/background-removals` 并返回 `queued`,没有轮询远端任务、下载完成媒体或写入本地 manifest;BgFilter 已成功处理但 Agent 因此永远只能看到受理回执。 +- 决策:抠图作为 `LocalProjectResourceEditKind::BackgroundRemoval` 接入现有资源编辑账本,模式和背景色写入 operation 身份;提交后复用同一套轮询、结果下载、staging、manifest 提交和恢复逻辑。已有账本优先恢复,禁止在未知结果时换 operation/idempotency 重发。 +- 边界:主站异步队列、BgFilter 和 SpacetimeDB schema 不变;Agent 只获得本地完成资源和安全身份投影,不接触内部 worker 或凭据。 +- 恢复:已受理任务中断后按原 operation 续查;提交结果不确定时保留账本并人工对账。升级前无账本的 queued 回执不自动迁移或重发,已有远端成果通过正式素材导入恢复。 +- 展示:恢复面板按账本显示抠图模式,平面背景模式同时显示已记录的自动背景色或颜色值,缺失字段不推断默认值,帮助区分同名待处理任务。 + ## 2026-09-17 Jenkins 公网入口 jenkins.genarrative.world 复用 router 反向隧道口径 - 背景:Jenkins controller 实际与 Gitea 同机运行在 `genarrative-station`(`jenkins.service`,`--httpPort=8080 --prefix=/jenkins`,`JENKINS_HOME=/var/lib/jenkins`),此前只有内网入口 `http://192.168.35.82:8080/jenkins/`;`router.genarrative.world` 已有「dev Nginx → dev loopback → station 反向隧道」的成熟口径。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d59e89037..2a440a3b8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## AGC 空快照测试必须等待请求完成 + +`waitFor(() => expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。 + ## AGC Windows 开发态首次页面加载缓慢 Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时会创建大量 Windows 文件监听器。AGC 配置通过 `server.watch.ignored: ['**/src-tauri/target/**']` 排除此目录,不关闭业务源码、CSS、共享组件监听或 HMR。排查时区分后端就绪、Vite 扫描和原生窗口首绘;监听目录回归不能代替实机首绘测量,验证入口见本地开发运维文档。 diff --git a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md index a7e9525fd..f32fcffa6 100644 --- a/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md +++ b/docs/technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md @@ -61,10 +61,25 @@ OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和 } ``` -客户端保留旧参数调用;新字段不填时不改变旧调用语义。客户端不读取图片、不自动选色、不把 `auto` 改写为具体颜色,使用原有 Bearer 认证、幂等键和队列返回模型。 +客户端保留旧参数调用;新字段不填时不改变模式语义。客户端不自动选色、不把 `auto` 改写为具体颜色;源资源校验、认证、异步任务查询、结果下载和本地登记由客户端负责。 工具 schema、桥接参数校验和随包 `agc-client-projection` Skill/契约说明必须保持一致。模式与颜色属于请求意图,必须参与客户端幂等指纹;同一图片与名称的不同模式不能复用同一次请求。缺省 complex 且没有颜色时保留既有指纹。主站在默认值归一化之前计算 External 请求指纹,缺失的新字段不序列化,避免旧请求重放发生冲突。 +客户端提交前建立的本地项目绑定会返回主站远端 `projectId` 与 `assetFolderId`,抠图请求必须使用这两个远端身份;本地 manifest `projectId` 仅用于绑定和本地状态,不能直接提交给主站。 + +### 本地结果与恢复合同 + +`agc_remove_background` 复用本地资源编辑账本,类型为 `background-removal`。源图片保持不变,抠图结果作为新资源写入项目。模式和背景色随账本持久化并参与请求指纹,其它编辑类型的历史指纹保持不变。 + +1. 客户端建立源图片的正式资源绑定,在提交前保存 operation、幂等键和请求意图。普通登录态提交 `/api/editor/images/background-removals`,从 `data.queueState.operationId` 读取受理身份;开发者模式提交 External v1 对应路由,从 `data.operationId` 读取身份。 +2. 受理后持续查询账号路由 `/api/runtime/external-generation/jobs/{operationId}` 或 External v1 对应状态路由。`queued`、`running` 只描述远端返回状态;固定进度值、本地文件缺失或 pending 清单为空均不能证明 BgFilter 排队。 +3. 远端 completed 后按稳定资源身份换取有效下载 URL,校验结果为带 alpha 通道的有效 PNG,随后复用 staging、manifest 和 revision 提交。只在本地登记完成后向 Agent 返回 `completed`、`operationId`、`resource.localAssetId`、相对路径和安全告警,不暴露临时 URL 或凭据。 +4. 轮询中断、超时或下载失败保留已受理 operation;`agc_list_registered_assets.pendingOperations` 与客户端恢复面板可见。相同源资源、结果名称、模式和颜色的后续调用优先恢复同一任务,不再次提交。不同账号不能恢复原账号任务;切回原账号后按既有恢复规则续接。 +5. 远端 failed 明确失败;提交响应不确定且无法确认 operation 时进入人工对账状态,不自动换键重发。失败/未知均不得伪造透明图或自动切换本地抠图方式。 +6. 升级前仅返回 queued、没有本地账本的任务不自动迁移;已有远端结果须通过正式资源查询和导入恢复,不据旧回执重新发起付费请求。 + +本修复只扩展 AGC 客户端现有工作流,不修改主站队列、BgFilter 或 SpacetimeDB schema。验收覆盖账号与开发者两种响应封装、queued/running/completed、已受理中断恢复不重复 POST、远端失败不登记结果,以及既有资源编辑回归。 + ## 实施任务 ### 任务一:冻结 BgFilter 契约 @@ -93,6 +108,13 @@ OpenAPI 与客户端工具的对外说明只描述模式用途、参数约束和 ## 验收证据 +2026-09-17 客户端闭环验证: + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell project::resource_editor:: -- --test-threads=1` 通过。新增 HTTP fixture 覆盖开发者 queued/running/completed、账号 HTTP 200 queueState 与 `data.job`、换签下载、PNG alpha 校验、原图保留和新资源提交。 +- 已受理任务的首次轮询失败后,pending 保留模式与颜色;恢复只查询原 operation,整个流程只 POST 一次,成功后清除 pending。远端 failed 不新增结果资源。既有账号隔离、提交原子性和崩溃恢复用例通过。 +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell agent::direct_tool_bridge::tests -- --test-threads=1`、AGC 类型检查、技能包校验、Rust 格式、编码、文档索引与 diff 检查通过。 +- 本轮未运行真实登录客户端 → 本地主站 → BgFilter 的端到端 smoke;当前本地后端已停止,自动化证据使用模拟 HTTP 服务。此前 BgFilter 成功日志只证明上游处理完成,不证明主站结果持久化或客户端导入成功。 + 2026-09-16 实测: - 主站 `cargo test -p api-server background_removal`:36 项通过,覆盖非法请求入队前拒绝、缺省 complex、队列参数保留、旧请求指纹、父侧内部 RPC 和 provider multipart。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 77c5d8108..3c864437b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,9 @@ ## 资源画布交互与工作台状态同步 +- 未完成抠图的恢复项在现有类型行显示账本中的复杂/平面背景模式;平面模式显示已记录的自动背景色或颜色值,缺失模式/颜色不补默认值,恢复仍按原 operation 身份执行。 +- 活动回合初始空快照、首次成功读取的空结果及禁用后的空态保持同一数组引用;快照签名初值与空态重置值均为 `[]`。无原生 invoke 的窗口测试只期待首次状态发布,异步空结果测试显式控制请求完成,不以初始空数组作为请求已完成的证据。 + - 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。 - 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。 @@ -167,7 +170,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm - 项目路径、projectId、当前 revision、源文件路径与媒体类型、operationId、Idempotency-Key、登录态、项目锁、付费提交、轮询恢复、下载校验与 manifest 事务全部由客户端持有。模型不能提交或覆盖这些字段。同一 Direct `clientTurnId + 规范语义参数` 生成稳定 UUID v4 身份;单回合同参重试复用原 operation,不同请求串行且最多四项。跨回合存在完全匹配的 pending 账本时优先恢复原 operation,不能换键重发。 - 资源查询同时投影未完成 operation 的安全状态。媒体工具成功只返回 operation、本地相对路径、资源类型、Canvas/resource/asset/task 身份、正式序列帧以及脱敏后的 `warnings / sliceWarnings`;错误继续使用统一脱敏边界。客户端资源账本持久化 completed 结果的两类告警,committed replay 不能把历史告警伪装成空集合。 - 角色动画、视频、音效和背景音乐在构造新的远端请求前统一准备当前项目同名画布与素材目录上下文,并在端点支持时携带 `projectId / assetFolderId / canvasCompletion`。角色动画 placeholder 使用源图片真实宽高,避免非方形角色进入画布时失真;正式 resource/asset 与序列帧继续直接复用 External 返回身份,不从首帧伪造重复资源。已有冻结 request body 或已受理 operation 保持不变,不因本次升级重建请求或重复扣费。 -- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份,调用 External v1 `/api/external/v1/editor/images/background-removals` 后只返回有界队列状态。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。 +- 抠图通过新增 `agc_remove_background` 语义工具开放:模型只提交当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端解析稳定 `resourceId`,准备同名画布/素材目录并生成稳定 operation/idempotency 身份。普通登录态使用账号鉴权的 `/api/editor/images/background-removals`,ExternalDeveloper 模式使用 External v1 `/api/external/v1/editor/images/background-removals`;客户端接收异步受理后轮询任务状态,下载完成媒体并登记到本地 manifest,未知结果保留同一 operation 供恢复。抠图服务仍由客户端和服务端负责源校验、BgFilter、素材登记与画布事务,Codex 不获得内部 worker、凭据或任意 API 调用权。 ## 2026-08-23 AGC 资源生成补齐(视频 / 动画 / 音效 / 背景音乐) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f92b841a..bc3f69744 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -6266,17 +6266,19 @@ pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( pub async fn remove_editor_image_background( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); + let idempotency_key = optional_editor_idempotency_key(&headers)?; let queue_job = enqueue_editor_background_removal_for_owner( &state, &request_context, &caller, payload, - None, + idempotency_key, ) .await?; Ok(json_success_body( diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index bd03cd7ab..d4ffc48d5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -825,8 +825,9 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) - .parentElement! - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + .parentElement!.style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), ).toBe(inverseScale); expect( ( From 105591bac5aacd04cb171bed3200b48a3556af13 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:51:59 +0800 Subject: [PATCH 52/68] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=B8=8E=E8=B5=84=E6=BA=90=E9=9D=A2=E6=9D=BF=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=89=B9=E9=87=8F=E8=BF=BD=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用标签编辑器并冻结整组选中素材,保留原标签与素材类型 原生批量校验后一次保存,覆盖权限、版本冲突与无变化边界 修复保存输入锁和切项目迟到回包,补齐混合选择入口原因 新增前后端回归并记录本地验证及待真实客户端验收事项 --- .../src-tauri/src/commands.rs | 21 + .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/manifest.rs | 234 ++++++ .../project/manifest/classification_tests.rs | 713 ++++++++++++++++++ .../ResourceCanvasPanelView.tsx | 37 +- .../resource-canvas/resourceCanvasChrome.css | 6 + .../ResourceClassificationPanel.tsx | 338 ++++++++- .../src/view/project-development/index.tsx | 240 +++++- .../resourceBatchTagTargetModel.ts | 84 +++ .../tests/resourceBatchTagTargetModel.test.ts | 92 +++ .../resourceBatchTagsIntegration.test.tsx | 680 +++++++++++++++++ .../resourceCanvasPanelBatchTags.test.tsx | 126 ++++ .../resourceClassificationPanel.test.tsx | 7 +- ...ourceClassificationPanelBatchTags.test.tsx | 346 +++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 1 + ...【实施计划】多选素材批量标签-2026-09-17.md | 6 +- .../【里程碑】多选素材批量标签-2026-09-17.md | 24 +- .../shared-memory/team-conventions.md | 2 + 18 files changed, 2905 insertions(+), 53 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d3a8f48ba..74170f9e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2320,6 +2320,27 @@ pub(crate) fn update_local_project_resource_classification( ) } +/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, +/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。 +/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision, +/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。 +#[tauri::command] +pub(crate) fn add_local_project_resource_tags( + input: AddLocalProjectResourceTagsInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + add_manifest_asset_tags_at( + root, + &input.expected_project_id, + input.expected_project_revision, + input.asset_ids, + input.tags, + ) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 68ba228eb..666dfa388 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2589,6 +2589,7 @@ fn main() { register_local_asset, create_ui_design_resource, update_local_project_resource_classification, + add_local_project_resource_tags, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index d5c37f54f..4f8b0c793 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1343,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at( }) } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) asset_ids: Vec, + #[serde(default)] + pub(crate) tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsResult { + pub(crate) assets: Vec, + pub(crate) committed_project_revision: u64, +} + +/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。 +/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。 +pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200; + +/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。 +/// +/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍: +/// +/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了 +/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写; +/// - 空批次失败; +/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描), +/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。 +fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result, String> { + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("批量标签 assetId 不能为空".to_string()); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS { + return Err(format!( + "批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材" + )); + } + } + } + if normalized.is_empty() { + return Err("批量标签至少需要一个素材".to_string()); + } + Ok(normalized) +} + +/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用 +/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。 +/// +/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘, +/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。 +fn normalize_manifest_batch_tags(tags: &[String]) -> Result, String> { + let normalized = normalize_manifest_asset_tags(tags)?; + if normalized.is_empty() { + return Err("批量标签不能为空".to_string()); + } + Ok(normalized) +} + +/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。 +/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。 +fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec { + let mut merged = existing.to_vec(); + for tag in incoming { + if !merged.iter().any(|current| current == tag) { + merged.push(tag.clone()); + } + } + merged +} + +/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。 +struct ManifestAssetTagAppendPlan { + /// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。 + assets: Vec, + /// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。 + updates: Vec<(usize, Vec)>, + /// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。 + changed_asset_ids: Vec, +} + +/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。 +/// +/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误; +/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。 +fn plan_manifest_asset_tag_append( + manifest: &GameCreationAppManifest, + asset_ids: &[String], + tags: &[String], +) -> Result { + let mut assets = Vec::with_capacity(asset_ids.len()); + let mut updates: Vec<(usize, Vec)> = Vec::with_capacity(asset_ids.len()); + let mut changed_asset_ids = Vec::new(); + for asset_id in asset_ids { + let index = manifest + .assets + .iter() + .position(|asset| &asset.id == asset_id) + .ok_or_else(|| format!("项目资源不存在:{asset_id}"))?; + let asset = &manifest.assets[index]; + // 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。 + // 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点** + // 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。 + let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags)) + .map_err(|error| { + format!( + "素材 {}({})的标签合并结果不合法:{error};本次未写入任何素材", + asset.id, asset.local_path + ) + })?; + if merged != asset.tags { + changed_asset_ids.push(asset.id.clone()); + } + updates.push((index, merged.clone())); + assets.push(GameCreationAppAssetManifestEntry { + tags: merged, + ..asset.clone() + }); + } + Ok(ManifestAssetTagAppendPlan { + assets, + updates, + changed_asset_ids, + }) +} + +/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、 +/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前), +/// 但作用域是**整批**: +/// +/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS; +/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**; +/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision; +/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。 +/// +/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威 +/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。 +pub(crate) fn add_manifest_asset_tags_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + asset_ids: Vec, + tags: Vec, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("批量标签 expectedProjectId 不能为空".to_string()); + } + let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?; + let tags = normalize_manifest_batch_tags(&tags)?; + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + // 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径); + // 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。 + let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + // no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。 + // 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。 + let plan = plan_manifest_asset_tag_append( + &read_existing_manifest_for_project(root)?, + &asset_ids, + &tags, + )?; + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + let plan = mutate_manifest_at(root, |manifest| { + // 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。 + // 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿 + // 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。 + let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?; + for (index, merged) in &plan.updates { + manifest.assets[*index].tags = merged.clone(); + } + Ok(plan) + })?; + + // 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容, + // 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。 + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + append_agent_db_record( + root, + serde_json::json!({ + "recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + "assetIds": plan.changed_asset_ids, + "expectedProjectRevision": expected_project_revision, + "appendedTags": tags, + }), + ) + .map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?; + Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision, + }) +} + +/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。 +pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append"; + pub(crate) fn create_manifest_task_at( root: &Path, task_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index d66a0d736..0dab003fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -559,3 +559,716 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { assert_eq!(asset.tags, vec!["主舞台"]); fs::remove_dir_all(root).ok(); } + +/// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 +fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { + register_local_asset_entry( + root, + local_path, + kind, + media_type, + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register asset") + .id +} + +fn project_with_batch_tag_assets( + test_name: &str, + specs: &[(&str, &str, &str)], +) -> (PathBuf, Vec) { + let root = classification_test_root(test_name); + init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") + .expect("initialize batch tag project"); + let ids = specs + .iter() + .map(|(local_path, kind, media_type)| { + register_batch_tag_asset(&root, local_path, kind, media_type) + }) + .collect::>(); + (root, ids) +} + +fn batch_tag_project_id(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .project_id +} + +fn batch_tag_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +fn batch_tag_manifest_bytes(root: &Path) -> Vec { + fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes") +} + +/// 批量追加的 `recordType`:与 `asset.*` 命名族一致,`.append` 表达"只追加、不替换既有标签"。 +fn batch_tag_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "批量标签测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_BATCH_TAG_AUDIT_RECORD_TYPE) + }) + .collect() +} + +fn batch_tag_manifest_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("manifest asset") +} + +/// 成功路径:只追加,原有标签顺序、分类与其它字段原样保留,未选中的素材一个字节都不变。 +/// 返回顺序按去重后的请求 ID 首次出现顺序;整批只推进一次 revision、只追加一条审计。 +#[test] +fn batch_tags_append_keeps_existing_tags_and_categories() { + let (root, ids) = project_with_batch_tag_assets( + "batch-append", + &[ + ("assets/hero.png", "character", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ("assets/untouched.png", "image", "image/png"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + // 两份目标素材带着**不同的**原有标签与分类进入:一个已有标签、一个是空标签集。 + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &ids[0], + "scene", + vec!["原甲".to_string(), "原乙".to_string()], + ) + .expect("preset first asset classification"); + let revision_after_preset = batch_tag_revision(&root); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_preset, + &ids[1], + "audio", + Vec::new(), + ) + .expect("preset second asset classification"); + let revision_before_batch = batch_tag_revision(&root); + let untouched_before = batch_tag_manifest_entry(&root, &ids[2]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before_batch, + vec![ids[1].clone(), ids[0].clone()], + vec!["新一".to_string(), "新二".to_string()], + ) + .expect("append batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + vec![ids[1].clone(), ids[0].clone()], + "返回条目必须按去重后请求 ID 的首次出现顺序" + ); + assert_eq!(result.assets[0].tags, vec!["新一", "新二"]); + assert_eq!(result.assets[1].tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(result.committed_project_revision, revision_before_batch + 1); + assert_eq!(batch_tag_revision(&root), revision_before_batch + 1); + + let first = batch_tag_manifest_entry(&root, &ids[0]); + assert_eq!(first.tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(first.category, GameCreationAppAssetCategory::Scene); + assert_eq!(first.kind, "character"); + assert_eq!(first.local_path, "assets/hero.png"); + assert_eq!(first.media_type, "image/png"); + let second = batch_tag_manifest_entry(&root, &ids[1]); + assert_eq!(second.tags, vec!["新一", "新二"]); + assert_eq!(second.category, GameCreationAppAssetCategory::Audio); + assert_eq!(second.kind, "background-music"); + assert_eq!(batch_tag_manifest_entry(&root, &ids[2]), untouched_before); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "整批只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![ + serde_json::Value::String(ids[1].clone()), + serde_json::Value::String(ids[0].clone()) + ]) + ); + assert_eq!( + audit[0] + .get("expectedProjectRevision") + .and_then(serde_json::Value::as_u64), + Some(revision_before_batch) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 重复 ID 与重复标签都按第一次出现收口:既不重复写入素材,也不重复追加同一个标签。 +#[test] +fn batch_tags_dedupe_asset_ids_and_tags() { + let (root, ids) = project_with_batch_tag_assets( + "batch-dedupe", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![format!(" {} ", ids[0]), ids[0].clone(), ids[1].clone()], + vec![ + " 标签 ".to_string(), + "标签".to_string(), + " 另一个 ".to_string(), + ], + ) + .expect("append deduped batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "去重后按首次出现顺序返回,两个素材各一次" + ); + for asset in &result.assets { + assert_eq!(asset.tags, vec!["标签", "另一个"]); + } + assert_eq!(result.committed_project_revision, revision_before + 1); + fs::remove_dir_all(root).ok(); +} + +/// 整批无变化:不写盘(manifest 字节不变)、不审计、不推进 revision,返回当前条目与当前 revision。 +#[test] +fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { + let (root, ids) = project_with_batch_tag_assets( + "batch-noop", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let first = add_manifest_asset_tags_at( + &root, + &project_id, + batch_tag_revision(&root), + ids.clone(), + vec!["重复标签".to_string()], + ) + .expect("first batch append"); + let revision_after_first = first.committed_project_revision; + assert_eq!(batch_tag_audit_records(&root).len(), 1); + let bytes_before = batch_tag_manifest_bytes(&root); + + let repeat = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_first, + vec![ids[0].clone(), ids[1].clone(), ids[0].clone()], + vec!["重复标签".to_string(), " 重复标签 ".to_string()], + ) + .expect("repeating the same batch tags must succeed"); + + assert_eq!(repeat.committed_project_revision, revision_after_first); + assert_eq!(repeat.assets.len(), 2); + for asset in &repeat.assets { + assert_eq!(asset.tags, vec!["重复标签"]); + } + assert_eq!( + batch_tag_manifest_bytes(&root), + bytes_before, + "无变化时不得重写 manifest" + ); + assert_eq!(batch_tag_revision(&root), revision_after_first); + assert_eq!( + batch_tag_audit_records(&root).len(), + 1, + "无变化不得追加假变更审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 混合批次:一部分目标无变化、一部分目标有变化时,审计只记**实际变化**的素材, +/// 响应仍然按请求顺序返回**全部**目标的最新条目,revision 只推进一次。 +#[test] +fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { + let (root, ids) = project_with_batch_tag_assets( + "batch-mixed", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + // 第一项已经有目标标签(本批对它无变化),第二项没有(本批真正改动它)。 + let revision_after_preset = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + vec!["已有".to_string()], + ) + .expect("preset first asset tags") + .committed_project_revision; + let first_before = batch_tag_manifest_entry(&root, &ids[0]); + let second_before = batch_tag_manifest_entry(&root, &ids[1]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_preset, + ids.clone(), + vec!["已有".to_string()], + ) + .expect("mixed no-op and change batch"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "响应必须按请求顺序返回全部目标素材" + ); + assert_eq!(result.assets[0].tags, vec!["已有"]); + assert_eq!(result.assets[1].tags, vec!["已有"]); + assert_eq!(result.committed_project_revision, revision_after_preset + 1); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[0]), + first_before, + "本批对该素材无变化时不得改写它" + ); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[1]).tags, + vec!["已有"], + "有变化的目标必须真实落盘" + ); + assert_ne!( + batch_tag_manifest_entry(&root, &ids[1]), + second_before, + "第二项应当发生改动" + ); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "混合批次只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![serde_json::Value::String(ids[1].clone())]), + "审计只记实际发生变化的素材" + ); + fs::remove_dir_all(root).ok(); +} + +/// 缺任一目标(含末项非法)时整批零部分写:已有素材的标签、manifest 字节与 revision 都不动。 +#[test] +fn batch_tags_missing_target_writes_nothing() { + let (root, ids) = project_with_batch_tag_assets( + "batch-missing", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), ids[1].clone(), "asset-missing".to_string()], + vec!["新标签".to_string()], + ) + .expect_err("a missing target must fail the whole batch"); + + assert!( + error.contains("项目资源不存在"), + "unexpected error: {error}" + ); + assert!(error.contains("asset-missing"), "unexpected error: {error}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert!( + batch_tag_manifest_entry(&root, asset_id).tags.is_empty(), + "缺目标失败后不得留下部分写入" + ); + } + fs::remove_dir_all(root).ok(); +} + +/// 合并后的标签总量与单标签长度按既有上界失败关闭:都不写盘、不推进 revision。 +#[test] +fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { + let (root, ids) = project_with_batch_tag_assets( + "batch-limits", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let sixteen = (0..16) + .map(|index| format!("原标签{index}")) + .collect::>(); + let revision_after_sixteen = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + sixteen.clone(), + ) + .expect("preset sixteen tags") + .committed_project_revision; + let fifteen = sixteen[..15].to_vec(); + let revision_after_fifteen = update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_sixteen, + &ids[1], + "audio", + fifteen, + ) + .expect("preset fifteen tags") + .committed_project_revision; + let bytes_before = batch_tag_manifest_bytes(&root); + + // 16 个原有标签 + 1 个新标签 = 17 > 上界:整批失败,两项目标都不动。 + let over_limit = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["再来一个".to_string()], + ) + .expect_err("a merged tag count above the limit must fail the whole batch"); + assert!(over_limit.contains("最多支持"), "unexpected: {over_limit}"); + // 通用上界文案必须能定位到具体素材:ID + 可读 localPath + 整批零写。 + assert!(over_limit.contains(&ids[0]), "unexpected: {over_limit}"); + assert!( + over_limit.contains("assets/hero.png"), + "unexpected: {over_limit}" + ); + assert!( + over_limit.contains("本次未写入任何素材"), + "unexpected: {over_limit}" + ); + + // 33 个字符的标签:单标签长度上界,同样整批失败。 + let over_chars = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["像".repeat(33)], + ) + .expect_err("an over-long tag must fail the whole batch"); + assert!(over_chars.contains("不能超过"), "unexpected: {over_chars}"); + // 单标签超长在**输入归一化**阶段就被拒绝,那时还没有任何"目标素材"可归因, + // 因此这里刻意不出现素材 ID;按素材定位是「合并后数量超限」这类锁内合并失败的职责。 + assert!(!over_chars.contains(&ids[0]), "unexpected: {over_chars}"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_after_fifteen); + assert!(batch_tag_audit_records(&root).is_empty()); + assert_eq!(batch_tag_manifest_entry(&root, &ids[0]).tags, sixteen); + + // 边界内必须成功:15 个原有标签 + 1 = 16,单标签 32 个字符。 + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + vec![ids[1].clone()], + vec!["像".repeat(32)], + ) + .expect("merged tag count exactly at the limit must succeed"); + assert_eq!(boundary.assets[0].tags.len(), 16); + assert_eq!(boundary.assets[0].tags[15].chars().count(), 32); + fs::remove_dir_all(root).ok(); +} + +/// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 +#[test] +fn batch_tags_rejects_empty_batch_and_empty_tags() { + let (root, ids) = + project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let tags = vec!["标签".to_string()]; + + let empty_batch = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + Vec::new(), + tags.clone(), + ) + .expect_err("an empty asset batch must be rejected"); + assert!( + empty_batch.contains("至少需要一个素材"), + "unexpected: {empty_batch}" + ); + + // 空白 assetId 一律拒绝:跳过它会让"请求了 2 个素材"变成"实际写了 1 个",且调用方仍拿到成功。 + for blank in [String::new(), " ".to_string()] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), blank], + tags.clone(), + ) + .expect_err("a blank assetId must fail the whole batch"); + assert!(error.contains("assetId 不能为空"), "unexpected: {error}"); + } + + for empty_tags in [Vec::new(), vec![String::new()], vec![" ".to_string()]] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + empty_tags, + ) + .expect_err("empty tags must be rejected"); + assert!(error.contains("不能为空"), "unexpected: {error}"); + } + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + assert!( + batch_tag_manifest_entry(&root, &ids[0]).tags.is_empty(), + "空白 assetId / 空标签失败后不得留下部分写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 批次上界:去重后 200 个素材成功,201 个失败且不写任何一项。 +#[test] +fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { + let root = classification_test_root("batch-bound-200"); + init_local_game_project_at(&root, "batch-tag-bound-project", "批量标签上界测试") + .expect("initialize batch bound project"); + let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) + .map(|index| { + register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + }) + .collect::>(); + let project_id = batch_tag_project_id(&root); + + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let over = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["超限".to_string()], + ) + .expect_err("more than the batch limit must be rejected"); + assert!(over.contains("最多支持 200 个素材"), "unexpected: {over}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids[..ASSET_BATCH_TAG_MAX_ASSETS].to_vec(), + vec!["批量".to_string()], + ) + .expect("exactly the batch limit must succeed"); + assert_eq!(boundary.assets.len(), ASSET_BATCH_TAG_MAX_ASSETS); + assert_eq!(boundary.committed_project_revision, revision_before + 1); + assert_eq!(boundary.assets[0].tags, vec!["批量"]); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[ASSET_BATCH_TAG_MAX_ASSETS]).tags, + Vec::::new(), + "第 201 个素材不在本批范围内,不得被写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 +#[test] +fn batch_tags_keeps_project_identity_and_revision_cas() { + let (root, ids) = + project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let identity_error = add_manifest_asset_tags_at( + &root, + "another-project", + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a foreign project identity must fail closed"); + assert_eq!(identity_error, "project-identity-conflict"); + + let revision_error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before + 1, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a stale revision must fail closed"); + assert_eq!(revision_error, "project-revision-conflict"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// DTO 必须 camelCase 且拒绝未知字段。 +#[test] +fn batch_tags_input_rejects_unknown_fields() { + let parsed = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + })) + .expect("camelCase payload must deserialize"); + assert_eq!(parsed.asset_ids, vec!["asset-1"]); + assert_eq!(parsed.tags, vec!["标签"]); + + let rejected = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + "unexpected": true, + })); + assert!(rejected.is_err()); +} + +/// 权限门面:`asset.register` 被拒绝时命令整体失败,manifest、revision 与审计都不动。 +#[test] +fn batch_tags_command_requires_asset_register_permission() { + let (root, ids) = project_with_batch_tag_assets( + "batch-permission", + &[("assets/hero.png", "image", "image/png")], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.register".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = + crate::commands::add_local_project_resource_tags(AddLocalProjectResourceTagsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + expected_project_revision: revision_before, + asset_ids: ids.clone(), + tags: vec!["新标签".to_string()], + }) + .expect_err("a denied asset.register must fail closed"); + + assert_eq!(error, "项目权限策略拒绝执行:asset.register"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// 审计失败必须照实报「整批已写入」,不谎称回滚、也不留下假审计。 +#[test] +fn batch_tags_audit_failure_reports_written_batch() { + let (root, ids) = project_with_batch_tag_assets( + "batch-audit-failure", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("已写入"), "unexpected error: {error}"); + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert_eq!( + batch_tag_manifest_entry(&root, asset_id).tags, + vec!["新标签"], + "manifest 是权威真相:已写入就必须能读回来,不伪报 rollback" + ); + } + assert_eq!( + batch_tag_revision(&root), + revision_before, + "审计失败发生在 revision 推进之前,照既有语义不推进" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx index 5ac80692d..abb2dedf2 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx @@ -1,4 +1,4 @@ -import { Download, Upload, X } from 'lucide-react'; +import { Download, ListFilter, Upload, X } from 'lucide-react'; import { useEffect, useId, useRef } from 'react'; import type { ResourceCanvasPanelEntry } from './resourceCanvasAssetTransferModel'; @@ -20,6 +20,16 @@ export type ResourceCanvasPanelViewProps = { onToggleEntry: (resourceId: string) => void; onSelectAll: () => void; onClearSelection: () => void; + /** + * 批量追加标签入口。由宿主给出「当前完整选中集」解析出的实际目标数量与禁用原因: + * 数量按去重后的已登记素材算(跨筛选保留的选择也算在内),不是只算面板可见项。 + * 未传时动作行不出现该入口。 + */ + batchTags?: { + targetCount: number; + blockedReason: string | null; + onOpen: () => void; + }; onUploadFiles: (files: FileList) => void; onDownloadSelection: () => void; isUploading: boolean; @@ -38,6 +48,7 @@ export function ResourceCanvasPanelView({ onToggleEntry, onSelectAll, onClearSelection, + batchTags, onUploadFiles, onDownloadSelection, isUploading, @@ -140,6 +151,23 @@ export function ResourceCanvasPanelView({ > 清空选择 + {batchTags ? ( + + ) : null}
+ {batchTags?.blockedReason ? ( +

{`批量标签不可用:${batchTags.blockedReason}`}

+ ) : null} + {notice ? (

{notice} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index a7f485eaf..1b4255781 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -1092,6 +1092,12 @@ font-size: 0.78rem; } +.game-resource-panel-batch-tag-reason { + margin: 0; + color: #8c6252; + font-size: 0.78rem; +} + .game-resource-panel-empty { margin: 0; color: #8c6252; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index 1bd9ed092..973dc09df 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -1,7 +1,7 @@ import '../../features/project-workspace/resourceClassificationTagPanel.css'; import { X } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge'; @@ -21,6 +21,15 @@ type UpdateLocalProjectResourceClassificationResult = { committedProjectRevision: number; }; +/** + * 批量追加标签的原生响应:`assets` 是按去重后请求 ID 首现顺序返回的整组最新条目, + * `committedProjectRevision` 是本批写入后的项目 revision(整批无变化时是当前值)。 + */ +export type AddLocalProjectResourceTagsResult = { + assets: GameCreationAppAssetManifestEntry[]; + committedProjectRevision: number; +}; + /** * 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 * 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。 @@ -41,27 +50,128 @@ function mergeResourceClassificationTagDraft( return next; } -function resourceClassificationErrorMessage(error: unknown) { +function resourceClassificationErrorMessage( + error: unknown, + fallback = '保存素材标签失败', +) { // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; // 与重命名、删除共用同一份映射。 - return projectAssetCommandErrorMessage(error, '保存素材标签失败'); + return projectAssetCommandErrorMessage(error, fallback); } -type ResourceClassificationPanelProps = { +/** + * 标签 pill 列表:单素材模式渲染**已有标签**(逐项可删),批量模式渲染**待追加草稿** + * (逐项可删,但删的是草稿、不是素材上已落盘的标签)。 + * + * 两个模式共用这一份实现:同在「编辑素材标签」面板内,不需要为此抽到 `packages/shared`; + * 真有第二个宿主面板用到带删除按钮的 pill 时再抽 `PlatformRemovableTagPill`,不要复制。 + */ +function ResourceTagPillList({ + ariaLabel, + removeAriaLabel, + tags, + onRemove, + disabled = false, +}: { + ariaLabel: string; + removeAriaLabel: (tag: string) => string; + tags: readonly string[]; + onRemove: (tag: string) => void; + /** 保存期间锁住 pill 上的删除(批量模式用;单素材模式保持既有行为不传)。 */ + disabled?: boolean; +}) { + if (tags.length === 0) return null; + return ( +

    + {tags.map((tag) => ( +
  • + + {tag} + + +
  • + ))} +
+ ); +} + +type ResourceClassificationPanelCommonProps = { projectPath: string; projectId: string; - asset: GameCreationAppAssetManifestEntry; onClose: () => void; - onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; }; -export function ResourceClassificationPanel({ +type ResourceSingleClassificationPanelProps = + ResourceClassificationPanelCommonProps & { + mode?: 'single'; + asset: GameCreationAppAssetManifestEntry; + onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; + }; + +type ResourceBatchClassificationPanelProps = + ResourceClassificationPanelCommonProps & { + mode: 'batch'; + /** + * 打开面板时由宿主冻结的目标集(去重、首现顺序)。本面板只读第一帧的值: + * 之后画布选中或资源面板筛选再变,也不改这一批的写入对象。 + */ + assetIds: readonly string[]; + onSaved: (result: AddLocalProjectResourceTagsResult) => void; + }; + +export type ResourceClassificationPanelProps = + | ResourceSingleClassificationPanelProps + | ResourceBatchClassificationPanelProps; + +function dedupeResourceAssetIds(assetIds: readonly string[]) { + const seen = new Set(); + const deduped: string[] = []; + for (const assetId of assetIds) { + if (seen.has(assetId)) continue; + seen.add(assetId); + deduped.push(assetId); + } + return deduped; +} + +/** + * 「编辑素材标签」面板:单素材模式与批量模式共用同一个面板骨架、同一套标签草稿 + * 拆分口径和同一把保存锁,只有编辑对象与提交命令不同。 + * + * - 单素材:既有增删标签行为不变(写入命令 `update_local_project_resource_classification`)。 + * - 批量:只把草稿里的标签**追加**到整组冻结素材(写入命令 `add_local_project_resource_tags`), + * 面板里不显示、也不允许删除各素材已有标签,更不会把已有标签并集当作提交值。 + */ +export function ResourceClassificationPanel( + props: ResourceClassificationPanelProps, +) { + return props.mode === 'batch' ? ( + + ) : ( + + ); +} + +/** 单素材标签编辑:既有增删标签行为,一次一份素材。 */ +function ResourceSingleClassificationPanel({ projectPath, projectId, asset, onClose, onSaved, -}: ResourceClassificationPanelProps) { +}: ResourceSingleClassificationPanelProps) { /** * 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。 * @@ -182,31 +292,12 @@ export function ResourceClassificationPanel({ 工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的 「添加」,导致"改了类型没生效"。 */} - {tags.length > 0 ? ( -
    - {tags.map((tag) => ( - // 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill, - // 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。 -
  • - - {tag} - - -
  • - ))} -
- ) : null} + `删除标签 ${tag}`} + tags={tags} + onRemove={removeTag} + /> ); } + +/** + * 批量追加标签:编辑对象是打开面板时冻结的整组素材 ID。 + * + * 面板里**只有待追加草稿** —— 各素材已有标签的并集既不显示、也不进入提交值, + * 因为「把并集分发给每一项」会给每份素材都补上别人的标签。保存就是一次 + * `add_local_project_resource_tags`,绝不逐素材循环调用单素材写入命令。 + */ +function ResourceBatchClassificationPanel({ + projectPath, + projectId, + assetIds, + onClose, + onSaved, +}: ResourceBatchClassificationPanelProps) { + /** + * 冻结目标集:只取第一帧。宿主在打开时已经快照了一份,这里再冻一次, + * 保证「面板已开、用户又改了画布选中或资源面板筛选」时这一批的写入对象不变。 + */ + const [targetAssetIds] = useState(() => dedupeResourceAssetIds(assetIds)); + const [draftTags, setDraftTags] = useState([]); + const [tagDraft, setTagDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + /** + * 同一事件里重复提交的同步兜底:`saving` 是 state,按钮的 `disabled` 要等下一次 + * 渲染才生效,双击 / 回车与点击连着来时会各发一次请求。这里用 ref 立刻上锁。 + */ + const inFlightRef = useRef(false); + + /** 与单素材模式同一份切分口径:回车 / 中英文逗号 / 顿号把草稿落成待追加 pill。 */ + function commitTagDraft() { + // 保存在飞时输入框已禁用,这里再兜一次:草稿改了也不会被提交,清掉只会误导用户。 + if (saving) return; + if (!tagDraft.trim()) return; + setDraftTags((current) => + mergeResourceClassificationTagDraft(current, tagDraft), + ); + setTagDraft(''); + } + + function removeDraftTag(tag: string) { + if (saving) return; + setDraftTags((current) => current.filter((item) => item !== tag)); + } + + async function appendResourceTags(tagsToAppend: readonly string[]) { + const tags = normalizeGameCreationAppAssetTags(tagsToAppend); + // 空草稿没有可追加内容:不提交、不读 revision,避免"空操作也报保存"。 + if (tags.length === 0) return; + if (inFlightRef.current) return; + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setError('批量追加标签需要在客户端内保存'); + return; + } + inFlightRef.current = true; + setSaving(true); + setError(null); + try { + // 与单素材写入同一口径:先读项目 revision,再带项目身份与版本 CAS 提交。 + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = await invoke( + 'add_local_project_resource_tags', + { + input: { + projectPath, + expectedProjectId: projectId, + expectedProjectRevision: status.revision, + assetIds: targetAssetIds, + tags, + }, + }, + ); + // 只有原生确认写入后才清草稿:失败(含 CAS 冲突)保留待追加标签供直接重试。 + setDraftTags([]); + setTagDraft(''); + onSaved(result); + } catch (saveError) { + setError(resourceClassificationErrorMessage(saveError, '批量追加标签失败')); + } finally { + inFlightRef.current = false; + setSaving(false); + } + } + + /** 底部唯一的「追加标签」= 把输入框尾巴(含没按回车的部分)落成 pill,然后一次保存。 */ + async function appendDraftAndSave() { + const tagsToAppend = mergeResourceClassificationTagDraft( + draftTags, + tagDraft, + ); + setDraftTags(tagsToAppend); + setTagDraft(''); + await appendResourceTags(tagsToAppend); + } + + const hasTagsToAppend = + normalizeGameCreationAppAssetTags( + mergeResourceClassificationTagDraft(draftTags, tagDraft), + ).length > 0; + + return ( + +
+
+

批量追加标签

+

{`已选 ${targetAssetIds.length} 项素材`}

+
+ +
+
+ {/* + 只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是 + 草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。 + */} + `移除待追加标签 ${tag}`} + tags={draftTags} + onRemove={removeDraftTag} + // 保存在飞时不许改草稿:新输入的标签不会被这次提交带上,成功后又会被清空, + // 用户会以为"改了但没保存"。锁住输入与删除,语义才是"这一批正在写"。 + disabled={saving} + /> + setTagDraft(event.currentTarget.value)} + onBlur={commitTagDraft} + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ',' || + event.key === ',' + ) { + event.preventDefault(); + commitTagDraft(); + } + }} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+
+ void appendDraftAndSave()} + disabled={saving || !hasTagsToAppend} + > + 追加标签 + +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8f8f0fbd1..be29e6474 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -247,6 +247,7 @@ import { uploadProjectAssetFilesAndReadSnapshot, } from './projectResourceLiveUpdateModel'; import { ResourceAssetDeleteDialog } from './ResourceAssetDeleteDialog'; +import { resolveResourceBatchTagTargets } from './resourceBatchTagTargetModel'; import { createResourceBookTransitionController, type ResourceBookTransitionController, @@ -2104,6 +2105,25 @@ export default function ProjectDevelopmentView({ useState(false); const [resourceClassificationAssetId, setResourceClassificationAssetId] = useState(null); + /** + * 批量追加标签的目标集:在入口点击那一刻冻结(去重后的资产 ID + 当时的项目身份)。 + * + * 为什么连项目身份一起冻:面板保存后要用原生返回的 revision 重新回读整份 manifest, + * 而那次回读是按 `projectPath` 发起的。切项目后迟到的回包只有比对打开时的路径与 + * 项目 ID 才能被识别出来并整条丢弃,不然旧项目的回读结果会覆盖新项目。 + */ + const [resourceBatchTagsTarget, setResourceBatchTagsTarget] = useState<{ + assetIds: string[]; + projectPath: string; + projectId: string; + /** + * 冻结时的项目代次。批量标签的异步链路跨两次 await(写盘 + 整份 manifest 回读), + * 期间切项目就丢弃结果;只用路径 + 项目 ID 挡不住 A → B → A(代次变了、身份字符串又对上了)。 + */ + epoch: number; + } | null>(null); + /** 批量标签的项目代次:项目身份一变就 +1(切走再切回也算两代)。 */ + const resourceBatchTagsEpochRef = useRef(0); /** * 正在设置素材类型(功能分类)的素材;与 `resourceClassificationAssetId`(标签)分开持有: * 两块面板是两个独立入口,一块开着不该把另一块的宿主状态也算成开着。 @@ -2506,6 +2526,16 @@ export default function ProjectDevelopmentView({ [], ); + /** + * 项目身份推进一代:批量标签这类跨 await 的链路靠代次判断结果是否还属于当前项目。 + * 放在身份赋值之前,比的是上一帧的身份 —— A → B → A 会 +2,旧代次的回包因此被丢弃。 + */ + if ( + currentProjectIdentityRef.current.projectPath !== projectPath || + currentProjectIdentityRef.current.projectId !== manifest.projectId + ) { + resourceBatchTagsEpochRef.current += 1; + } currentProjectIdentityRef.current = { projectPath, projectId: manifest.projectId, @@ -2732,11 +2762,44 @@ export default function ProjectDevelopmentView({ * 只登记游戏代码的项目也会在「待归类」栏目正常显示卡片。 */ const canvasResources = resources; + /** + * 批量追加标签的目标解析:只吃**完整选中集**(含资源面板跨筛选保留的选择), + * 不按首项或当前可见项截断。解析失败(不足 2 项、混入版本 / 未登记素材、超限) + * 时把原因直接给用户,入口保持禁用。 + */ + const resourceBatchTagTargets = useMemo( + () => resolveResourceBatchTagTargets(canvasResources, selectedResourceIds), + [canvasResources, selectedResourceIds], + ); /** 资源画布选中态:单选是长度为 1 的数组,多选/框选保持同一份状态。 */ const selectedResourceId = selectedResourceIds[0] ?? null; const selectedResource = canvasResources.find((resource) => resource.id === selectedResourceId) ?? null; + /** + * 画布选中工具栏「编辑标签」的入口判定: + * - 选中 1 项:既有的单素材标签编辑(增删标签行为不变); + * - 选中 ≥2 项:批量追加标签,对象是冻结后的完整选中集; + * - 多选里混入版本 / 未登记素材或超限:入口禁用并给出原因,绝不只改首项。 + */ + const resourceTagEditorEntry = useMemo(() => { + if (selectedResourceIds.length >= 2) { + return resourceBatchTagTargets.ok + ? { mode: 'batch' as const, disabledReason: null, assetId: null } + : { + mode: 'batch' as const, + disabledReason: resourceBatchTagTargets.reason, + assetId: null, + }; + } + return selectedResource?.manifestAssetId + ? { + mode: 'single' as const, + disabledReason: null, + assetId: selectedResource.manifestAssetId, + } + : null; + }, [resourceBatchTagTargets, selectedResource, selectedResourceIds.length]); /** * 信息浮层只对打开它的那次选中有效:换选、清空选中、切项目都会关掉它, * 面板里不会留下上一张资源的陈旧信息。放在选中派生处而不是逐个重置点补一行, @@ -2987,6 +3050,10 @@ export default function ProjectDevelopmentView({ useEffect(() => { setResourceVersionReplacementLineage(null); }, [manifest.projectId, projectPath]); + /** 批量标签的冻结目标不跨项目:切项目就关掉面板(迟到回包的丢弃见保存处理)。 */ + useEffect(() => { + setResourceBatchTagsTarget(null); + }, [manifest.projectId, projectPath]); useEffect(() => { const report = dependencyLayout.readReport ?? typeLayout.readReport; if ( @@ -3617,12 +3684,24 @@ export default function ProjectDevelopmentView({ * 删除与版本替换则相反 —— 操作对象在重载后已经变了,收起面板才是对的。所以默认仍然收起, * 只有标签保存显式传 `true`。关闭面板本身属于宿主状态(`resourceClassificationAssetId`), * 不是面板自己调 `onClose`,所以这个开关只能放在这里。 + * + * `options.isStillCurrent`(可选):**回读完成之后**再判一次「这次结果还属于当前项目吗」。 + * 提交回包前的核对挡不住"回读期间切项目"——那时 `get_local_game_manifest` 已经在飞, + * 回来的是旧项目的整份清单,写进 `onManifestChange` 就会盖掉新项目。只有批量标签会传 + * 这个判定(带项目代次),其它动作的行为保持不变。 + * + * `options.readBackFailureNotice`(可选):写入已经提交、只是刷新失败时的提示口径。 + * 批量标签据此说明"标签已保存,但刷新失败",不把已落盘的写入说成保存失败。 */ const reloadManifestAfterAssetCommand = useCallback( async ( committedProjectRevision: number, commitId: string, - options: { keepClassificationPanelOpen?: boolean } = {}, + options: { + keepClassificationPanelOpen?: boolean; + isStillCurrent?: () => boolean; + readBackFailureNotice?: (error: unknown) => string; + } = {}, ) => { if (!options.keepClassificationPanelOpen) { setResourceClassificationAssetId(null); @@ -3635,6 +3714,8 @@ export default function ProjectDevelopmentView({ 'get_local_game_manifest', { projectPath, commandId: 'asset.list' }, ); + // 回读期间项目可能已经切换:旧项目的整份清单绝不能写进新项目。 + if (options.isStillCurrent && !options.isStillCurrent()) return; if (next.projectId !== manifest.projectId) return; onManifestChange(projectPath, next, { projectId: next.projectId, @@ -3644,7 +3725,11 @@ export default function ProjectDevelopmentView({ }); } catch (error) { setResourceWorkbenchNotice( - error instanceof Error ? error.message : String(error), + options.readBackFailureNotice + ? options.readBackFailureNotice(error) + : error instanceof Error + ? error.message + : String(error), ); } }, @@ -3664,6 +3749,80 @@ export default function ProjectDevelopmentView({ }, [reloadManifestAfterAssetCommand], ); + /** + * 打开批量追加标签:目标集在点击这一刻冻结(去重后的资产 ID + 当前项目身份)。 + * + * 返回是否真的打开:调用方(资源面板入口)据此决定要不要顺带收起资源面板 —— + * 解析失败时不该把资源面板也关掉,用户还需要在那里调整选择。 + */ + const openResourceBatchTags = useCallback(() => { + if (!resourceBatchTagTargets.ok) { + setResourceWorkbenchNotice(resourceBatchTagTargets.reason); + return false; + } + setResourceBatchTagsTarget({ + assetIds: [...resourceBatchTagTargets.assetIds], + projectPath, + projectId: manifest.projectId, + epoch: resourceBatchTagsEpochRef.current, + }); + return true; + }, [manifest.projectId, projectPath, resourceBatchTagTargets]); + /** + * 批量追加标签保存成功后的宿主回写。 + * + * 用原生返回的 revision 走**完整 manifest 回读**(`get_local_game_manifest` + + * `onManifestChange`),不按可能已经过期的闭包 manifest 拼一份局部结果 —— + * 同一时间别人改了别的素材时,局部拼装会把那些改动覆盖掉。 + * + * 回读失败只报错(`reloadManifestAfterAssetCommand` 落到工作台提示条), + * 不假装"没改过":写入本身已经在原生侧提交了。 + * + * 有效性判定要跨两次 await 都成立(写盘回包、整份 manifest 回读),所以判定函数比较的是 + * **项目代次 + 路径 + 项目 ID**:只比路径与项目 ID 时,A → B → A 会让旧代次的回读结果 + * 重新"对上身份",把上一轮的项目清单盖回来。 + */ + const handleResourceBatchTagsSaved = useCallback( + async ( + target: { projectPath: string; projectId: string; epoch: number }, + result: { + assets: GameCreationAppAssetManifestEntry[]; + committedProjectRevision: number; + }, + ) => { + const isTargetCurrent = () => { + const current = currentProjectIdentityRef.current; + return ( + resourceBatchTagsEpochRef.current === target.epoch && + current.projectPath === target.projectPath && + current.projectId === target.projectId + ); + }; + /** + * 切项目后迟到的提交回包:代次或身份对不上就整条丢弃。 + * 少了这道门禁,旧项目的 manifest 回读会经 `onManifestChange` 盖掉新项目。 + */ + if (!isTargetCurrent()) return; + await reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `asset-tags-batch:${result.assets.map((asset) => asset.id).join(',')}`, + // 与单素材标签一致:保存成功后保持面板打开(草稿已清空,可继续追加下一批), + // 关闭只走头部 ×。 + { + keepClassificationPanelOpen: true, + // 回读完成后再判一次:回读期间切项目时,回来的是旧项目的整份清单。 + isStillCurrent: isTargetCurrent, + // 原生已经写入成功,只是整份 manifest 没读回来:说清"已保存、刷新失败", + // 不给用户"保存失败、其实已落盘"的错觉。 + readBackFailureNotice: (error) => + `标签已保存,但刷新失败:${ + error instanceof Error ? error.message : String(error) + }`, + }, + ); + }, + [reloadManifestAfterAssetCommand], + ); const handleResourceClassificationDeleted = useCallback( async (result: { assetId: string; committedProjectRevision: number }) => { await reloadManifestAfterAssetCommand( @@ -9052,7 +9211,14 @@ export default function ProjectDevelopmentView({ selectedResource && isResourceDocumentPreviewable(selectedResource), ) || - selectedResourceOpensUiEditor) ? ( + selectedResourceOpensUiEditor || + /* + * 批量追加标签的入口就在这条工具条上:首项是项目版本 / 未登记素材时, + * 上面几条都不成立,但整份选择集里可能仍有可写入的已登记素材 + * (或者需要把"为什么不能批量写"显示出来)。工具条不能整条消失, + * 也不新开一条平行工具条。 + */ + resourceTagEditorEntry !== null) ? ( 引用 ) : null} - {selectedResource?.manifestAssetId ? ( + {resourceTagEditorEntry ? ( } - onClick={() => - setResourceClassificationAssetId( - selectedResource.manifestAssetId, - ) + // 多选无法整批写入(混入版本 / 未登记素材或超限)时 + // 入口禁用,并把原因挂在 title 上;绝不静默只改首项。 + title={ + resourceTagEditorEntry.disabledReason ?? + '编辑标签' } + disabled={ + resourceTagEditorEntry.disabledReason !== + null + } + icon={} + onClick={() => { + if ( + resourceTagEditorEntry.mode === 'batch' + ) { + openResourceBatchTags(); + return; + } + setResourceClassificationAssetId( + resourceTagEditorEntry.assetId, + ); + }} > 编辑标签 @@ -10083,6 +10264,28 @@ export default function ProjectDevelopmentView({ ) } onClearSelection={() => setSelectedResourceIds([])} + batchTags={{ + // 数量按完整选中集解析出的**实际目标**算:跨筛选保留的选择也要进这个数字, + // 不能只算面板当前可见项。 + targetCount: resourceBatchTagTargets.ok + ? resourceBatchTagTargets.assetIds.length + : 0, + // 空选择不给原因(面板刚打开时本来就没什么可选);有选择但解析不过 + // (版本 / 未登记素材、不足 2 项、超限)才显示禁用原因。 + blockedReason: + selectedResourceIds.length === 0 || resourceBatchTagTargets.ok + ? null + : resourceBatchTagTargets.reason, + onOpen: () => { + /* + * 同一次事件里收起资源面板、打开标签面板(两次 setState 交给 React 批处理, + * 调用顺序不影响结果):资源面板自己监听 window 的 Escape,两层同时开着时 + * 同一次 Escape 会连标签面板一起关掉。 + * 返回时不恢复资源面板 —— 面板里已选/已筛选状态本来就活在宿主,不需要它回来。 + */ + if (openResourceBatchTags()) setResourcePanelOpen(false); + }, + }} onUploadFiles={(files) => void uploadResourcePanelFiles(files)} onDownloadSelection={() => void downloadResourcePanelEntries(selectedResourcePanelEntries) @@ -10106,6 +10309,23 @@ export default function ProjectDevelopmentView({ onSaved={(result) => void handleResourceClassificationSaved(result)} /> ) : null} + {/* + 批量追加标签:`resourceBatchTagsTarget` 是入口点击时冻结的目标(含当时的项目身份), + 面板只读这一份 —— 之后画布换选、资源面板换筛选都不改这一批的写入对象。 + */} + {resourceBatchTagsTarget ? ( + setResourceBatchTagsTarget(null)} + onSaved={(result) => + void handleResourceBatchTagsSaved(resourceBatchTagsTarget, result) + } + /> + ) : null} {/* 「设置素材类型」:与标签面板并列的独立入口(素材类型 = 功能分类)。 面板自己是"选中即落盘",宿主这里负责保存成功后收起面板 —— 用户必须马上在画布上 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts new file mode 100644 index 000000000..d7829366a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBatchTagTargetModel.ts @@ -0,0 +1,84 @@ +/** + * 多选素材批量追加标签的**目标集解析**(纯模型)。 + * + * 批量标签只对「当前入口展示的完整选中集合」生效,且整批要么全写、要么不写。 + * 因此打开面板前必须先把选中集解析成一份**去重后的 manifest 资产 ID 列表**, + * 或者给出一个明确的禁用原因: + * + * - 选择集里含未登记素材(项目版本 / 附件 / 任务产物 / Agent 回执)时不能只写其中一部分; + * - 选择集里含已被删除、投影里已不存在的 ID 时同上; + * - 去重后不足 2 项、超过批次上限时也不进入批量模式。 + * + * 返回顺序固定为「去重后请求 ID 的首次出现顺序」,与原生返回条目的顺序口径一致。 + * 这里不读 manifest、不碰 Tauri,只吃投影与选中 ID,方便直接钉住上述判定。 + */ + +/** 每批最多 200 个不同素材(按去重后的资产 ID 数量计算)。 */ +export const RESOURCE_BATCH_TAG_MAX_ASSETS = 200; + +/** 批量标签的目标:打开面板时冻结的资产 ID 列表(首现顺序、已去重)。 */ +export type ResourceBatchTagTarget = { + assetIds: string[]; +}; + +export type ResourceBatchTagTargetResolution = + | { ok: true; assetIds: string[] } + | { ok: false; reason: string }; + +export function resolveResourceBatchTagTargets( + resources: readonly { + id: string; + manifestAssetId: string | null; + }[], + selectedResourceIds: readonly string[], +): ResourceBatchTagTargetResolution { + const selected = new Set(); + const selectedIds: string[] = []; + for (const resourceId of selectedResourceIds) { + if (selected.has(resourceId)) continue; + selected.add(resourceId); + selectedIds.push(resourceId); + } + + const resourcesById = new Map(resources.map((item) => [item.id, item])); + const missingIds = selectedIds.filter( + (resourceId) => !resourcesById.has(resourceId), + ); + if (missingIds.length > 0) { + return { + ok: false, + reason: '选择里含已被删除的素材,不能只保存其中一部分;请重新选择', + }; + } + + const registeredIds: string[] = []; + let hasUnregistered = false; + for (const resourceId of selectedIds) { + const assetId = resourcesById.get(resourceId)?.manifestAssetId ?? null; + if (!assetId) { + hasUnregistered = true; + continue; + } + if (!registeredIds.includes(assetId)) registeredIds.push(assetId); + } + if (hasUnregistered) { + return { + ok: false, + reason: + '选择里含未登记素材(项目版本 / 附件 / 任务产物),不能只保存其中一部分;请排除后重试', + }; + } + if (registeredIds.length < 2) { + return { + ok: false, + reason: '批量追加标签至少要选择 2 项已登记素材', + }; + } + if (registeredIds.length > RESOURCE_BATCH_TAG_MAX_ASSETS) { + return { + ok: false, + reason: `每批最多 ${RESOURCE_BATCH_TAG_MAX_ASSETS} 项素材,请缩小选择`, + }; + } + return { ok: true, assetIds: registeredIds }; +} diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts new file mode 100644 index 000000000..d15cbe4cc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest'; + +import { + resolveResourceBatchTagTargets, + RESOURCE_BATCH_TAG_MAX_ASSETS, +} from '../src/view/project-development/resourceBatchTagTargetModel'; + +function registered(id: string, assetId: string) { + return { id, manifestAssetId: assetId }; +} + +describe('resolveResourceBatchTagTargets', () => { + test('按选中集首现顺序给出去重后的资产 ID,顺序不按 manifest 排', () => { + const resolution = resolveResourceBatchTagTargets( + [registered('asset:bg', 'asset-bg'), registered('asset:hero', 'asset-hero')], + ['asset:hero', 'asset:bg', 'asset:hero'], + ); + + expect(resolution).toEqual({ ok: true, assetIds: ['asset-hero', 'asset-bg'] }); + }); + + test('不足 2 项已登记素材时不进入批量模式', () => { + const resolution = resolveResourceBatchTagTargets( + [registered('asset:hero', 'asset-hero')], + ['asset:hero'], + ); + + expect(resolution.ok).toBe(false); + }); + + test('混入未登记素材(项目版本 / 附件 / 任务产物)时整批拒绝而不是只写已登记项', () => { + const resolution = resolveResourceBatchTagTargets( + [ + registered('asset:hero', 'asset-hero'), + registered('asset:npc', 'asset-npc'), + { id: 'version:v1', manifestAssetId: null }, + ], + ['asset:hero', 'asset:npc', 'version:v1'], + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain('未登记素材'); + }); + + test('选中集里的 ID 在投影里已不存在(素材被删)时同样拒绝', () => { + const resolution = resolveResourceBatchTagTargets( + [registered('asset:hero', 'asset-hero')], + ['asset:hero', 'asset:deleted'], + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain('已被删除'); + }); + + test(`去重后超过 ${RESOURCE_BATCH_TAG_MAX_ASSETS} 项时按上限拒绝`, () => { + const resources = Array.from( + { length: RESOURCE_BATCH_TAG_MAX_ASSETS + 1 }, + (_, index) => registered(`asset:a${index}`, `asset-a${index}`), + ); + const resolution = resolveResourceBatchTagTargets( + resources, + resources.map((item) => item.id), + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain(String(RESOURCE_BATCH_TAG_MAX_ASSETS)); + + const atLimit = resources.slice(0, RESOURCE_BATCH_TAG_MAX_ASSETS); + expect( + resolveResourceBatchTagTargets( + atLimit, + atLimit.map((item) => item.id), + ).ok, + ).toBe(true); + }); + + test('多个资源 ID 指向同一个资产时按资产去重,不把同一份素材算两遍', () => { + const resolution = resolveResourceBatchTagTargets( + [ + registered('asset:hero', 'asset-hero'), + registered('attachment:hero', 'asset-hero'), + ], + ['asset:hero', 'attachment:hero'], + ); + + // 去重后只剩 1 份素材:批量模式没有可写入的整组,直接拒绝。 + expect(resolution.ok).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx new file mode 100644 index 000000000..aee260a75 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -0,0 +1,680 @@ +/** @vitest-environment jsdom */ +import { useState } from 'react'; +import { beforeEach } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + expect, + findResourceSelectButton, + fireEvent, + ProjectDevelopmentView, + render, + screen, + vi, + waitFor, + within, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/batch-tags-project'; +const PROJECT_ID = 'batch-tags-project'; +const OTHER_PROJECT_PATH = '/tmp/batch-tags-other'; +const OTHER_PROJECT_ID = 'batch-tags-other'; + +const disk = { + manifest: null as GameCreationAppManifest | null, + revision: 3, +}; + +function createFixtureManifest( + projectId = PROJECT_ID, + name = '批量标签项目', +): GameCreationAppManifest { + const manifest = createGameCreationAppManifest(projectId, name); + manifest.assets = [ + { + id: 'asset-hero', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' }, + }, + { + id: 'asset-npc', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/npc.png', + source: { kind: 'uploaded' }, + }, + ]; + // 一个正式项目版本:它在投影里是「未登记素材」,用来钉混合选择不能部分写入。 + manifest.versions = [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + return manifest; +} + +function graphFor(manifest: GameCreationAppManifest) { + const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type BatchTagWrite = { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + assetIds: string[]; + tags: string[]; +}; + +function appendTagsToDisk(input: BatchTagWrite) { + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const assets = base.assets.map((asset) => + input.assetIds.includes(asset.id) + ? ({ + ...asset, + tags: [ + ...(asset.tags ?? []), + ...input.tags.filter((tag) => !(asset.tags ?? []).includes(tag)), + ], + } as (typeof base.assets)[number]) + : asset, + ); + disk.manifest = { ...base, assets }; + return { + assets: assets.filter((asset) => input.assetIds.includes(asset.id)), + committedProjectRevision: disk.revision, + }; +} + +/** + * 真宿主接线:壳持有清单状态,画布是它的消费者,写入后用 + * `onManifestChange(projectPath, next)` 回写这一份状态。 + * + * 项目切换按「同一份工作台实例换 props」模拟(不卸载、不换 key): + * 这正是迟到回包必须被身份门禁拦住的场景。 + */ +function BatchTagsHost({ + projectPath, + projectId, +}: { + projectPath: string; + projectId: string; +}) { + const [scope, setScope] = useState(() => ({ + projectPath, + projectId, + manifest: createFixtureManifest(projectId), + })); + if (scope.projectPath !== projectPath || scope.projectId !== projectId) { + setScope({ projectPath, projectId, manifest: createFixtureManifest(projectId) }); + } + disk.manifest = scope.manifest; + + return ( + undefined} + onProjectsOpen={() => undefined} + supervisor={
} + onManifestChange={(path, next) => { + manifestChanges.push({ path, projectId: next.projectId }); + // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 + setScope((current) => + current.projectPath === path + ? { ...current, manifest: next } + : current, + ); + }} + /> + ); +} + +const manifestChanges: Array<{ path: string; projectId: string }> = []; + +function installHostTauri( + options: { + deferBatchWrite?: boolean; + failBatchWriteWith?: string; + /** 写入已提交后的那次整份 manifest 回读挂在飞,用来测"回读期间切项目"。 */ + deferManifestReadAfterWrite?: boolean; + failManifestReadAfterWriteWith?: string; + } = {}, +) { + const batchWrites: BatchTagWrite[] = []; + const classificationWrites: Array> = []; + let manifestReads = 0; + let batchWriteCompleted = false; + let deferredManifestRead: (() => void) | null = null; + let resolveDeferredBatchWrite: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: disk.revision }; + } + if (command === 'get_local_game_manifest') { + manifestReads += 1; + const snapshot = disk.manifest; + if (!snapshot) throw new Error('missing manifest fixture'); + if (batchWriteCompleted && options.failManifestReadAfterWriteWith) { + throw new Error(options.failManifestReadAfterWriteWith); + } + if ( + batchWriteCompleted && + options.deferManifestReadAfterWrite && + deferredManifestRead === null + ) { + return await new Promise((resolve) => { + deferredManifestRead = () => resolve(snapshot); + }); + } + return snapshot; + } + if (command === 'add_local_project_resource_tags') { + const input = (args?.input ?? {}) as BatchTagWrite; + batchWrites.push(structuredClone(input)); + if (options.failBatchWriteWith) { + throw new Error(options.failBatchWriteWith); + } + if (options.deferBatchWrite) { + return await new Promise((resolve) => { + resolveDeferredBatchWrite = () => resolve(appendTagsToDisk(input)); + }); + } + const committed = appendTagsToDisk(input); + batchWriteCompleted = true; + return committed; + } + if (command === 'update_local_project_resource_classification') { + const input = (args?.input ?? {}) as { + assetId?: string; + category?: string; + tags?: string[]; + }; + classificationWrites.push(structuredClone(input)); + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const nextManifest: GameCreationAppManifest = { + ...base, + assets: base.assets.map((asset) => + asset.id === input.assetId + ? ({ + ...asset, + category: input.category, + tags: input.tags ?? [], + } as (typeof base.assets)[number]) + : asset, + ), + }; + disk.manifest = nextManifest; + const asset = nextManifest.assets.find( + (entry) => entry.id === input.assetId, + ); + if (!asset) throw new Error(`missing asset ${String(input.assetId)}`); + return { asset, committedProjectRevision: disk.revision }; + } + if (command === 'read_local_project_resource_graph') { + return graphFor(disk.manifest!); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'read_local_project_resource_document') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 1, + content: '', + }; + } + if ( + command === 'read_local_project_image_preview' || + command === 'read_local_project_text_preview' + ) { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke: invoke as never }, + event: { listen: (async () => () => undefined) as never }, + }; + return { + invoke, + batchWrites, + classificationWrites, + manifestReads: () => manifestReads, + resolveDeferredBatchWrite: () => resolveDeferredBatchWrite?.(), + hasDeferredManifestRead: () => deferredManifestRead !== null, + resolveDeferredManifestRead: () => deferredManifestRead?.(), + }; +} + +async function openCategoryPage(name: string) { + fireEvent.click(await screen.findByRole('button', { name })); +} + +async function openResourcePanel() { + fireEvent.click(screen.getByRole('button', { name: '资源面板' })); + return within(await screen.findByRole('dialog', { name: '资源面板' })); +} + +/** + * 在资源面板里按顺序累加选中项(`onToggleEntry` 就是画布那份共享选中集的 append 入口), + * 关闭面板后画布上留下的就是同一份多选。 + */ +async function selectPanelEntriesInOrder( + labels: readonly string[], + options: { clearFirst?: boolean } = {}, +) { + const panel = await openResourcePanel(); + if (options.clearFirst) { + fireEvent.click(panel.getByRole('button', { name: '清空选择' })); + } + for (const label of labels) { + fireEvent.click(panel.getByRole('button', { name: `选择 ${label}` })); + } + fireEvent.click(panel.getByRole('button', { name: '关闭资源面板' })); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(), + ); +} + +beforeEach(() => { + disk.manifest = null; + disk.revision = 3; + manifestChanges.length = 0; +}); + +describe('多选素材批量追加标签(真实工作台)', () => { + it('资源面板入口按完整选中集打开批量面板,一次写入整组而不是首项', async () => { + const { batchWrites, classificationWrites, manifestReads } = + installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const readsBefore = manifestReads(); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + + // 标签面板打开时资源面板先让位:两层都监听 Escape 时同一次按键会连标签面板一起关掉。 + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + // 只显示待追加草稿:既有标签不进面板,也不会被当成提交值。 + expect(within(dialog).queryByRole('list', { name: '已有标签' })).toBeNull(); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角, 配角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + expect(batchWrites[0]).toEqual({ + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: 3, + assetIds: ['asset-hero', 'asset-npc'], + tags: ['主角', '配角'], + }); + // 绝不能退化成单素材写入(那正是"只改首项")。 + expect(classificationWrites).toHaveLength(0); + + // 成功后用原生返回的 revision 回读整份 manifest,两项都带上了新标签。 + await waitFor(() => expect(manifestReads()).toBeGreaterThan(readsBefore)); + await waitFor(() => { + expect( + disk.manifest?.assets.map((asset) => [asset.id, asset.tags]), + ).toEqual([ + ['asset-hero', ['主角', '配角']], + ['asset-npc', ['主角', '配角']], + ]); + }); + }); + + it('混合选择含正式项目版本时入口禁用并给出原因,不会只写已登记的两项', async () => { + const { batchWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开所有资源'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const button = (await waitFor(() => + panel.getByRole('button', { name: '批量标签' }), + )) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(panel.getByText(/批量标签不可用:.*未登记素材/u)).not.toBeNull(); + + fireEvent.click(button); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + }); + + it('单选仍走单素材面板:既有增删标签行为不变,选项不给批量入口', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + fireEvent.click(await findResourceSelectButton('hero.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites).toHaveLength(1)); + expect(classificationWrites[0]).toMatchObject({ + assetId: 'asset-hero', + category: 'character', + tags: ['主角'], + }); + expect(batchWrites).toHaveLength(0); + }); + + it('原生拒绝整批写入时不留部分结果,面板保留待追加草稿并报出可读原因', async () => { + // 原生按 CAS 冲突拒绝本批写入:整批不写、面板报可读原因、草稿留着可直接重试。 + const { batchWrites } = installHostTauri({ + failBatchWriteWith: 'project-revision-conflict', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => + expect( + within(dialog).getByRole('alert').textContent, + ).toBe('项目已被其它操作改动,请刷新后重试'), + ); + // 整批只发出一次请求,失败后 revision 不推进、草稿保留(重试不用重填)。 + expect(batchWrites).toHaveLength(1); + expect(disk.revision).toBe(3); + expect( + within(dialog) + .getByRole('list', { name: '待追加标签' }) + .textContent, + ).toContain('春节'); + }); + + it('切项目关闭批量面板并丢弃迟到回包,不把旧项目清单回写到新项目', async () => { + const { resolveDeferredBatchWrite } = installHostTauri({ + deferBatchWrite: true, + }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入还没回来时切项目:面板必须关掉。 + view.rerender( + , + ); + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: '批量追加标签' }), + ).toBeNull(), + ); + + const changesBeforeLateWrite = manifestChanges.length; + resolveDeferredBatchWrite(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 迟到回包不得触发旧项目的 manifest 回写(更没有盖到新项目上)。 + expect(manifestChanges.length).toBe(changesBeforeLateWrite); + expect(manifestChanges.map((change) => change.path)).not.toContain( + PROJECT_PATH, + ); + }); + + it('整份 manifest 回读期间切项目:回读完成后仍要判断有效性,旧项目清单不回写', async () => { + const { + batchWrites, + hasDeferredManifestRead, + resolveDeferredManifestRead, + } = installHostTauri({ deferManifestReadAfterWrite: true }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入已经提交,回读整份清单还挂在飞 —— 此刻切项目正是最危险的时间窗: + // 提交回包前的核对早就过去了,回来的是旧项目的完整清单。 + await waitFor(() => expect(batchWrites).toHaveLength(1)); + await waitFor(() => expect(hasDeferredManifestRead()).toBe(true)); + + const oldProjectChangesBeforeSwitch = () => + manifestChanges.filter((change) => change.path === PROJECT_PATH).length; + const oldProjectChangesAtSwitch = oldProjectChangesBeforeSwitch(); + + view.rerender( + , + ); + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: '批量追加标签' }), + ).toBeNull(), + ); + + resolveDeferredManifestRead(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 回读结果属于已经离开的旧项目:不得触发旧路径的 manifest 回写。 + expect(oldProjectChangesBeforeSwitch()).toBe(oldProjectChangesAtSwitch); + }); + + it('原生写入成功但整份 manifest 回读失败时,说明已保存、只是刷新失败', async () => { + const { batchWrites } = installHostTauri({ + failManifestReadAfterWriteWith: '读取项目清单超时', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 写入已在原生侧提交:提示必须是「已保存、刷新失败」,不能谎报保存失败。 + await waitFor(() => + expect( + screen.getByText(/标签已保存,但刷新失败:读取项目清单超时/u), + ).not.toBeNull(), + ); + // 已在磁盘上的写入确实生效(只是宿主没拿到新清单)。 + expect( + disk.manifest?.assets.map((asset) => asset.tags), + ).toEqual([['春节'], ['春节']]); + }); + + it('画布多选 2 份真实素材时,「编辑标签」打开的就是批量模式并按整组写入', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + // 画布与资源面板共用同一份选中集:这里按顺序累加 hero、npc, + // 关掉面板后画布上的多选就是这两份已登记素材。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 整批两份都在写入对象里,而不是只改首项。 + expect(batchWrites[0]?.assetIds).toEqual(['asset-hero', 'asset-npc']); + expect(classificationWrites).toHaveLength(0); + }); + + it('首项是项目版本时工具条不整条消失:批量入口禁用并给出原因(两种选择顺序一致)', async () => { + const { batchWrites } = installHostTauri(); + render(); + + // 版本(未登记素材)排在第一项:整份选择集不能整批写入, + // 但工具条必须还在,把"为什么不能批量写"显示出来。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['版本 1', 'hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const versionFirst = within(toolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(versionFirst.disabled).toBe(true); + expect(versionFirst.title).toContain('未登记素材'); + + fireEvent.click(versionFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + + // 反向顺序(已登记素材在前、版本在后)给出同一份禁用原因: + // 入口判据只看整份选择集,不取决于哪一项被先选中。 + await selectPanelEntriesInOrder(['hero.png', 'npc.png', '版本 1'], { + clearFirst: true, + }); + + const assetFirstToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const assetFirst = within(assetFirstToolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(assetFirst.disabled).toBe(true); + expect(assetFirst.title).toBe(versionFirst.title); + fireEvent.click(assetFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx new file mode 100644 index 000000000..5f36e9b5c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + resolveResourceCanvasPanelEntries, +} from '../src/features/resource-canvas/resourceCanvasAssetTransferModel'; +import { ResourceCanvasPanelView } from '../src/features/resource-canvas/ResourceCanvasPanelView'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +afterEach(cleanup); + +function createResource(overrides: Partial = {}): ProjectResource { + return { + id: 'asset:hero', + category: 'character', + subtype: 'character', + label: '主角立绘', + path: 'assets/hero.png', + mediaType: 'image/png', + sourceLabel: '生成', + taskTitle: null, + manifestAssetId: 'asset-hero', + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + ...overrides, + }; +} + +const entries = resolveResourceCanvasPanelEntries([ + { + resource: createResource(), + categoryLabel: '角色与对象', + typeLabel: '图片', + previewIdentity: null, + previewStatus: 'idle', + previewSourceUrl: null, + previewError: null, + }, + { + resource: createResource({ + id: 'asset:npc', + label: '配角立绘', + path: 'assets/npc.png', + manifestAssetId: 'asset-npc', + }), + categoryLabel: '角色与对象', + typeLabel: '图片', + previewIdentity: null, + previewStatus: 'idle', + previewSourceUrl: null, + previewError: null, + }, +]); + +function renderPanel( + overrides: { + batchTags?: { + targetCount: number; + blockedReason: string | null; + onOpen: () => void; + }; + } = {}, +) { + render( + , + ); + return within(screen.getByRole('dialog', { name: '资源面板' })); +} + +describe('资源面板动作行的批量标签入口', () => { + it('入口显示完整选中集解析出的实际目标数量,而不是面板可见项数量', () => { + const onOpen = vi.fn(); + const panel = renderPanel({ + // 面板可见 2 项,但本次选择跨筛选保留了 5 项:数字必须按实际目标显示。 + batchTags: { targetCount: 5, blockedReason: null, onOpen }, + }); + + fireEvent.click(panel.getByRole('button', { name: '批量标签(5)' })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('混合选择或超限时入口禁用,并把原因显示在动作行下方', () => { + const onOpen = vi.fn(); + const panel = renderPanel({ + batchTags: { + targetCount: 0, + blockedReason: + '选择里含未登记素材(项目版本 / 附件 / 任务产物),不能只保存其中一部分;请排除后重试', + onOpen, + }, + }); + + const button = panel.getByRole('button', { name: '批量标签' }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(button.title).toContain('未登记素材'); + expect( + panel.getByText(/批量标签不可用:选择里含未登记素材/u), + ).not.toBeNull(); + + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('宿主没给批量入口(未接线)时动作行不出现该按钮', () => { + const panel = renderPanel(); + + expect(panel.queryByRole('button', { name: '批量标签' })).toBeNull(); + expect(panel.getByRole('button', { name: '清空选择' })).not.toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 2df832dce..427ab3f43 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -439,8 +439,13 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { ); // 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。 + // 多选无法整批写入(混入版本 / 未登记素材、超限)时 title 换成禁用原因, + // 所以这里钉的是「可用态的入口名」与「必须有禁用原因分支」,不再钉 title 字面量。 expect(viewSource).toContain('label="编辑标签"'); - expect(viewSource).toContain('title="编辑标签"'); + expect(viewSource).toContain("'编辑标签'"); + expect(viewSource).toMatch( + /title=\{\s*resourceTagEditorEntry\.disabledReason \?\?\s*'编辑标签'/u, + ); expect(viewSource).toContain('编辑标签'); expect(viewSource).not.toContain('label="分类与标签"'); }); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx new file mode 100644 index 000000000..b9bcb32e5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx @@ -0,0 +1,346 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel'; + +function installInvoke( + implementation: (command: string, args?: unknown) => Promise, +) { + const invoke = vi.fn(implementation); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof invoke } }; + } + ).__TAURI__ = { core: { invoke } }; + return invoke; +} + +function removeInvoke() { + delete ( + window as unknown as { + __TAURI__?: { core?: { invoke?: unknown } }; + } + ).__TAURI__; +} + +function renderBatchPanel( + overrides: { + assetIds?: readonly string[]; + onClose?: () => void; + onSaved?: (result: unknown) => void; + } = {}, +) { + const element = (assetIds: readonly string[]) => ( + + ); + const view = render(element(overrides.assetIds ?? ['asset-hero', 'asset-npc'])); + return { + rerenderWithAssetIds: (assetIds: readonly string[]) => + view.rerender(element(assetIds)), + }; +} + +/** 待追加草稿按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */ +function draftPillLabels() { + const list = screen.queryByRole('list', { name: '待追加标签' }); + if (!list) return []; + return within(list) + .getAllByRole('listitem') + .map((item) => item.textContent?.replace('×', '').trim()); +} + +function batchTagWrites(invoke: ReturnType) { + return invoke.mock.calls.filter( + ([command]) => command === 'add_local_project_resource_tags', + ); +} + +afterEach(() => { + cleanup(); + removeInvoke(); +}); + +describe('ResourceClassificationPanel 批量追加标签', () => { + test('只有待追加草稿:不显示任何已有标签,也不出现单素材写入命令', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return { assets: [], committedProjectRevision: 4 }; + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + expect( + screen.getByRole('heading', { name: '批量追加标签' }), + ).not.toBeNull(); + // 副标题是**实际目标数量**,不是首项素材名。 + expect(screen.getByText('已选 2 项素材')).not.toBeNull(); + expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull(); + expect(draftPillLabels()).toEqual([]); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节{Enter}', + ); + // 回车把草稿落成待追加 pill,但这不是素材已有标签。 + expect(draftPillLabels()).toEqual(['春节']); + + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1)); + expect(invoke.mock.calls.map(([command]) => command)).toEqual([ + 'get_local_game_project_revision', + 'add_local_project_resource_tags', + ]); + // 绝不逐素材循环调用单素材写入命令。 + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_classification', + ), + ).toHaveLength(0); + }); + + test('保存把整组冻结 ID 与去重后的标签一次性交给原生,先读 revision 再提交', async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'add_local_project_resource_tags') { + return { + assets: [{ id: 'asset-hero' }, { id: 'asset-npc' }], + committedProjectRevision: 8, + }; + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel({ + assetIds: ['asset-npc', 'asset-hero', 'asset-npc'], + onSaved, + }); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节, 新春,春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + const [, args] = batchTagWrites(invoke)[0]!; + expect(args).toEqual({ + input: { + projectPath: 'C:/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + // 首现顺序 + 去重:不是只写首项,也不重复同一份素材。 + assetIds: ['asset-npc', 'asset-hero'], + tags: ['春节', '新春'], + }, + }); + // 保存成功后草稿清空,面板可以继续追加下一批。 + expect(draftPillLabels()).toEqual([]); + }); + + test('打开面板后宿主再传新的目标集,保存仍用打开时冻结的那一组', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return { assets: [], committedProjectRevision: 4 }; + } + throw new Error(`unexpected command: ${command}`); + }); + const { rerenderWithAssetIds } = renderBatchPanel({ + assetIds: ['asset-hero', 'asset-npc'], + }); + + rerenderWithAssetIds(['asset-hero', 'asset-npc', 'asset-bg']); + expect(screen.getByText('已选 2 项素材')).not.toBeNull(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1)); + const [, args] = batchTagWrites(invoke)[0]!; + expect(args).toMatchObject({ + input: { assetIds: ['asset-hero', 'asset-npc'] }, + }); + }); + + test('空草稿不提交:按钮禁用、不读 revision、不写任何命令', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + const submit = screen.getByRole('button', { name: '追加标签' }); + expect(submit).toHaveProperty('disabled', true); + + await user.click(submit); + expect(invoke).not.toHaveBeenCalled(); + }); + + test('保存期间锁住重复提交与关闭,失败后保留待追加草稿并报出原因', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + let rejectSave: ((error: Error) => void) | null = null; + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'add_local_project_resource_tags') { + return new Promise((_resolve, reject) => { + rejectSave = reject; + }); + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel({ onClose }); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(rejectSave).not.toBeNull()); + // 保存在飞:重复提交与关闭(头部 ×)都被禁用。 + expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty( + 'disabled', + true, + ); + const closeButton = screen.getByRole('button', { + name: '关闭批量追加标签', + }); + expect(closeButton).toHaveProperty('disabled', true); + await user.click(closeButton); + await user.keyboard('{Escape}'); + expect(onClose).not.toHaveBeenCalled(); + + rejectSave!(new Error('project-revision-conflict')); + + // 失败:草稿保留(可直接重试),原因是可读中文而不是错误码。 + await waitFor(() => + expect(screen.getByRole('alert').textContent).toBe( + '项目已被其它操作改动,请刷新后重试', + ), + ); + expect(draftPillLabels()).toEqual(['春节']); + expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty( + 'disabled', + false, + ); + }); + + test('待追加草稿可以逐项移除,移除的只是草稿不是素材已有标签', async () => { + const user = userEvent.setup(); + installInvoke(async () => undefined); + renderBatchPanel(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节,', + ); + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '新春,', + ); + expect(draftPillLabels()).toEqual(['春节', '新春']); + + await user.click( + screen.getByRole('button', { name: '移除待追加标签 春节' }), + ); + expect(draftPillLabels()).toEqual(['新春']); + }); + + test('客户端外运行时不写盘,只给出提示', async () => { + const user = userEvent.setup(); + removeInvoke(); + renderBatchPanel(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + expect(screen.getByRole('alert').textContent).toContain('需要在客户端内'); + }); + + test('保存进行中锁住输入与草稿删除,同一批只发一次请求,成功后草稿才清空', async () => { + const user = userEvent.setup(); + let resolveSave: ((value: unknown) => void) | null = null; + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + const field = screen.getByPlaceholderText( + '新增标签,多个用逗号分隔', + ) as HTMLInputElement; + await user.type(field, '春节,'); + expect(draftPillLabels()).toEqual(['春节']); + + // 同一事件里的第二次点击也不能再发一次:`saving` 的 state 还没渲染出来, + // 靠的是同步的 inFlight 锁。 + const submit = screen.getByRole('button', { name: '追加标签' }); + fireEvent.click(submit); + fireEvent.click(submit); + + await waitFor(() => expect(resolveSave).not.toBeNull()); + expect(batchTagWrites(invoke)).toHaveLength(1); + + // 保存在飞:新输入不会被这一批带上,成功后又会被清空,所以输入框与草稿删除都锁住。 + expect(field.disabled).toBe(true); + fireEvent.change(field, { target: { value: '保存中乱敲的标签' } }); + // 禁用状态下这次输入进不了受控 state:草稿没变,保存成功后输入框仍是空的。 + expect(draftPillLabels()).toEqual(['春节']); + + const removeButton = screen.getByRole('button', { + name: '移除待追加标签 春节', + }) as HTMLButtonElement; + expect(removeButton.disabled).toBe(true); + fireEvent.click(removeButton); + expect(draftPillLabels()).toEqual(['春节']); + + resolveSave!({ assets: [], committedProjectRevision: 4 }); + await waitFor(() => expect(draftPillLabels()).toEqual([])); + expect(field.value).toBe(''); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 36f6c9fa7..2c1d633cf 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -63,6 +63,7 @@ - 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。 - 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。 - 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。 +- 多选已登记素材后,可从选中工具栏“编辑标签”或资源面板“批量标签”入口统一追加标签。面板明确实际目标数量,保存对象在打开时冻结,保留每项原标签及素材类型;不把已有标签并集覆盖到每项。混合未登记资源、超过 200 项、任何一项标签越界或项目版本冲突时整批拒绝,不静默跳过。一次保存更新整批素材,失败保留待追加标签;切项目不将迟到结果写入新项目。单素材编辑保留原有增删标签行为。 - 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 ` - ) : null} -
- ) : null} ) : null}
; + settle: (anchor: string | null) => void; + /** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */ + consumed: boolean; +}; + +/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */ +export function openDirectHistoryAnchorGate( + projectPath: string, +): DirectHistoryAnchorGate { + let settle: (anchor: string | null) => void = () => {}; + const anchor = new Promise((resolve) => { + settle = resolve; + }); + return { projectPath, anchor, settle, consumed: false }; +} + +/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */ +export function reuseOrOpenDirectHistoryAnchorGate( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate { + if (current && current.projectPath === projectPath) { + return current; + } + return openDirectHistoryAnchorGate(projectPath); +} + +/** + * 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。 + * + * 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过 + * (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。 + */ +export function directHistoryAnchorGateToWaitFor( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate | null { + const sameProjectGate = + current && current.projectPath === projectPath ? current : null; + if (!sameProjectGate) { + return openDirectHistoryAnchorGate(projectPath); + } + return sameProjectGate.consumed ? null : sameProjectGate; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts new file mode 100644 index 000000000..3065848b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts @@ -0,0 +1,84 @@ +/** + * DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。 + * + * 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏, + * 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后 + * 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。 + * + * 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。 + */ + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants'; +import { + type DirectChatEntry, + mergeHistoryEntries, + projectDirectHistoryItems, +} from './directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from './directThreadEvents'; +import { buildDirectChatTurns } from './directTurnPresentation'; + +export type DirectHistoryPages = { + /** 累积到的条目,保持文件顺序(旧 → 新)。 */ + items: DirectThreadItem[]; + /** 后端声明的"还有更早的历史";取不动时收口为 false。 */ + hasMore: boolean; + /** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */ + firstItemId: string | null; + /** 中断原因;`null` 表示正常停止。 */ + error: unknown; +}; + +function visibleTurnCount(entries: readonly DirectChatEntry[]): number { + return buildDirectChatTurns({ entries }).length; +} + +/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */ +function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) { + return [...pages].reverse().flat(); +} + +export async function readDirectHistoryPages({ + existingEntries, + beforeItemId, + readSlice, +}: { + /** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */ + existingEntries: readonly DirectChatEntry[]; + beforeItemId: string | null; + readSlice: (beforeItemId: string | null) => Promise; +}): Promise { + const baseTurnCount = visibleTurnCount(existingEntries); + const pages: DirectThreadItem[][] = []; + let anchor = beforeItemId; + let hasMore = false; + let error: unknown = null; + for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) { + let slice: DirectThreadHistorySlice; + try { + slice = await readSlice(anchor); + } catch (thrown) { + // 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。 + error = thrown; + break; + } + pages.push([...slice.items]); + hasMore = slice.hasMore; + const nextAnchor = slice.firstItemId; + if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) { + // 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。 + hasMore = false; + break; + } + anchor = nextAnchor; + if (!hasMore) break; + const merged = mergeHistoryEntries( + projectDirectHistoryItems(inFileOrder(pages)), + existingEntries, + ); + if (visibleTurnCount(merged) > baseTurnCount) break; + } + return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error }; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts new file mode 100644 index 000000000..76e4a5fa3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,272 @@ +/** + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject + * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; +import { projectDirectThreadItem } from './directThreadItemProjection'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: DirectChatToolCard | null; + at?: number; +}; + +export type DirectThreadChatState = { + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ + live: DirectChatEntry[]; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + turnRunning: false, + history: [], + live: [], + }; +} + +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; +} + +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; + return { + ...left, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), + detail: { + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, + }, + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: Math.max(left.updatedAt, right.updatedAt), + }; +} + +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + return { + itemId: existing.itemId || incoming.itemId, + kind: + existing.kind === 'tool' || incoming.kind === 'tool' + ? 'tool' + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, + }; +} + +function upsertLiveEntry( + state: DirectThreadChatState, + entry: DirectChatEntry, +): DirectThreadChatState { + const index = state.live.findIndex( + (existing) => existing.itemId === entry.itemId, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +function appendLiveText( + state: DirectThreadChatState, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': + return { ...state, turnRunning: true }; + case 'turn.completed': + // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 + return { + ...state, + turnRunning: false, + history: mergeHistoryEntries(state.history, state.live), + live: [], + }; + case 'item.delta': + return appendLiveText(state, event); + case 'item.started': + case 'item.completed': { + const entry = projectDirectThreadItem(event.item); + return entry ? upsertLiveEntry(state, entry) : state; + } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; + default: + return state; + } +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部 + * 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。 + */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, bootstrap.events); +} + +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( + state: DirectThreadChatState, + result: DirectThreadConsumeResult, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); + continue; + } + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); + } + return entries; +} + +/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */ +export function projectDirectHistoryItems( + items: readonly DirectThreadItem[], +): DirectChatEntry[] { + return items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); +} + +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadItem[], +): DirectThreadChatState { + return { + ...state, + history: mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ), + }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +export function selectDirectChatEntries( + state: DirectThreadChatState, +): DirectChatEntry[] { + return mergeHistoryEntries(state.history, state.live); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 32180fcff..741e694f3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,121 +1,17 @@ -import type { - ChatMessage, - LocalConversationMessageRecord, -} from '../../app/types'; +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadHistorySlice = { - items: unknown[]; - hasMore: boolean; - itemTimestamps?: Record; - oldestItemId?: string | null; -}; - -/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ -export function directThreadHistoryPage( - slice: DirectThreadHistorySlice, - previousCursor: string | null = null, -) { - const first = slice.items[0]; - const firstId = - first && typeof first === 'object' && 'id' in first - ? (first as { id?: unknown }).id - : null; - const cursor = - slice.oldestItemId ?? - (typeof firstId === 'string' && firstId ? firstId : null); - if (slice.hasMore && (!cursor || cursor === previousCursor)) { - throw new Error('对话历史分页游标未前进,请重新读取项目历史'); - } - return { - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), - hasMore: slice.hasMore, - cursor, - }; -} - -/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ -export function prependDirectHistoryMessages( - current: readonly ChatMessage[], - older: readonly ChatMessage[], -): ChatMessage[] { - const ids = new Set( - current.flatMap((message) => - message.messageId ? [message.messageId] : [], - ), - ); - const additions = older.filter((message) => { - if (!message.messageId) return true; - if (ids.has(message.messageId)) return false; - ids.add(message.messageId); - return true; - }); - return [...additions, ...current]; -} - -export function directThreadHistoryItemsToMessages( - items: unknown[], - itemTimestamps: Readonly> = {}, -): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; - if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; - return [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, - agentId: null, - messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, - }, - ]; - }); -} - -/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */ -export function isDirectTurnInProgress( - status: string | null | undefined, -): status is 'accepted' | 'running' | 'streaming' | 'finalizing' { - return ( - status === 'accepted' || - status === 'running' || - status === 'streaming' || - status === 'finalizing' - ); -} +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts new file mode 100644 index 000000000..1f44ddda0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,269 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + if (status === 'completed') return 'completed'; + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; + // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 + if (typeof exitCode === 'number') { + return exitCode === 0 ? 'completed' : 'failed'; + } + return status ? 'running' : 'completed'; +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + at: number; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; + if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || + detail.command || + changes[0]?.path || + input.tool || + ''; + return { + schemaVersion: 'agc-tool-call.v1', + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + status: input.status, + detail, + startedAt: input.at, + updatedAt: input.at, + }; +} + +function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }); + case 'function_call_output': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }); + case 'commandExecution': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }); + case 'fileChange': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }); + case 'mcpToolCall': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }); + case 'webSearch': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }); + case 'contextCompaction': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }); + default: + return null; + } +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 + */ +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, +): DirectChatEntry | null { + if (!item) return null; + // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 + const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : ''; + if (!itemId) return null; + + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index cd5327ef5..a481c03ed 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -1,30 +1,12 @@ -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../../app/types'; -import { projectSupervisorChatMessageText } from '../agent-runtime'; +/** + * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 + * + * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; + * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + */ -export function directConversationTurnId(messageId: string | null | undefined) { - const match = /^direct-codex:(.+):(user|assistant|failure)$/.exec( - messageId ?? '', - ); - return match?.[1] ?? null; -} - -export type DirectTurnPresentation = { - key: string; - turnId: string | null; - messages: ChatMessage[]; - notices: ChatMessage[]; - items: TurnStreamItem[]; - calls: GameCreatorDirectToolCall[]; - source: 'stream' | 'messages'; - active: boolean; - transientReply: string; - startedAt: number; - endedAt: number; -}; +import type { ChatMessage } from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; /** 统一 Direct 时间戳为 Unix 毫秒;历史/异常 payload 可能传 Unix 秒。 */ export function normalizeDirectTimestamp(value: number | undefined) { @@ -42,236 +24,264 @@ export function normalizeDirectTimestamp(value: number | undefined) { export const directMessageTimestamp = normalizeDirectTimestamp; -/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */ -export function splitDirectTurnContent(turn: DirectTurnPresentation) { - const assistants = turn.messages.filter( - (message) => message.role === 'assistant', - ); - const isFailureMessage = (message: ChatMessage) => - Boolean( - message.messageId?.endsWith(':failure') && - directConversationTurnId(message.messageId), +export type DirectChatBlock = + | { kind: 'user'; key: string; text: string; at: number } + | { + kind: 'assistant'; + key: string; + text: string; + at: number; + notice?: boolean; + } + | { kind: 'reasoning'; key: string; text: string } + | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; + +export type DirectChatTurn = { + key: string; + /** 用户气泡:顺序即发出顺序。 */ + users: DirectChatBlock[]; + /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ + process: DirectChatBlock[]; + /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + finals: DirectChatBlock[]; + active: boolean; + startedAt: number; + endedAt: number; +}; + +type DirectChatTurnEntries = { + key: string; + entries: DirectChatEntry[]; + /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ + localUsers: DirectChatBlock[]; + notices: ChatMessage[]; + active: boolean; +}; + +function blockFromEntry( + entry: DirectChatEntry, + key: string, +): DirectChatBlock | null { + if (entry.kind === 'tool') { + return entry.toolCall + ? { kind: 'tools', key, calls: [entry.toolCall] } + : null; + } + const text = entry.text ?? ''; + if (!text.trim()) return null; + if (entry.kind === 'reasoning') { + return { kind: 'reasoning', key, text }; + } + return entry.role === 'user' + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } + : { + kind: 'assistant', + key, + text, + at: normalizeDirectTimestamp(entry.at), + }; +} + +/** 连续的工具条目合成一块;中间夹了正文就分块。 */ +function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { + const merged: DirectChatBlock[] = []; + for (const block of blocks) { + const last = merged[merged.length - 1]; + if (block.kind === 'tools' && last?.kind === 'tools') { + last.calls.push(...block.calls); + continue; + } + merged.push( + block.kind === 'tools' ? { ...block, calls: [...block.calls] } : block, ); - const isFailureItem = (item: TurnStreamItem) => - item.id === `text:${turn.turnId}:failure`; - const failed = - assistants.some(isFailureMessage) || turn.items.some(isFailureItem); - const lastText = - !turn.active && !failed - ? turn.items - .filter((item) => item.kind === 'text' && item.text.trim()) - .at(-1) - : undefined; - const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined; + } + return merged; +} + +function blockFromLocalMessage( + message: ChatMessage, + index: number, +): DirectChatBlock | null { + const text = message.text.trim(); + if (!text) return null; + const key = message.messageId ?? `local:${index}`; + if (message.role === 'user') { + return { + kind: 'user', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + }; + } return { - processItems: turn.items.filter( - (item) => item !== lastText && !isFailureItem(item), - ), - finalItems: turn.items.filter( - (item) => item === lastText || isFailureItem(item), - ), - processMessages: assistants.filter( - (message) => message !== lastMessage && !isFailureMessage(message), - ), - finalMessages: assistants.filter( - (message) => message === lastMessage || isFailureMessage(message), - ), + kind: 'assistant', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + notice: true, }; } -/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */ -export function buildDirectTurnPresentations({ - messages, - visibleMessages, - items, - calls, - activeTurnId, - transientReply, - hasUnloadedHistory = false, -}: { - messages: readonly ChatMessage[]; - visibleMessages: readonly ChatMessage[]; - items: readonly TurnStreamItem[]; - calls: readonly GameCreatorDirectToolCall[]; - activeTurnId?: string | null; - transientReply: string; - hasUnloadedHistory?: boolean; -}): DirectTurnPresentation[] { - const rows = new Map(); - const itemTurnIds = new Map(); - for (const item of items) { - const prefix = `text:${item.turnId}:`; - if (item.kind === 'text' && item.id.startsWith(prefix)) { - itemTurnIds.set(item.id.slice(prefix.length), item.turnId); - } - } - const ensure = (turnId: string | null, key: string) => { - const existing = rows.get(key); - if (existing) return existing; - const row: DirectTurnPresentation = { - key, - turnId, - messages: [], - notices: [], - items: [], - calls: [], - source: 'messages', - active: Boolean(turnId && turnId === activeTurnId), - transientReply: '', - startedAt: 0, - endedAt: 0, - }; - rows.set(key, row); - return row; +function newTurn(key: string): DirectChatTurnEntries { + return { + key, + entries: [], + localUsers: [], + notices: [], + active: false, }; +} - // 原始 assistant id 可通过流 item 精确反查。旧用户记录无 turn id 时, - // 只用同一用户记录区间内的这种精确证据关联,不与第 N 个工具回合配对。 - const boundaryTurnIds = new Map(); - let boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const id = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? ''); - if (id && boundary >= 0 && !boundaryTurnIds.has(boundary)) { - boundaryTurnIds.set(boundary, id); - } - }); - const visible = new Set(visibleMessages); - const visibleKeys = new Set(); - const seenMessages = new Set(); - boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const turnId = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? '') ?? - boundaryTurnIds.get(boundary) ?? - null; - const key = turnId - ? `turn:${turnId}` - : `history:${boundary < 0 ? index : boundary}`; - const row = ensure(turnId, key); - if (visible.has(message)) visibleKeys.add(key); - if (message.messageId && seenMessages.has(message.messageId)) return; - if (message.messageId) seenMessages.add(message.messageId); - row.messages.push(message); - }); - - const itemsByKey = new Map(); - for (const item of items) { - const key = `${item.turnId}\0${item.id}`; - const previous = itemsByKey.get(key); - if (!previous || item.updatedAt >= previous.updatedAt) - itemsByKey.set(key, item); - } - for (const item of itemsByKey.values()) { - ensure(item.turnId, `turn:${item.turnId}`).items.push(item); - } - const callsByKey = new Map(); - for (const call of calls) { - const key = `${call.turnId}\0${call.id}`; - const previous = callsByKey.get(key); - if (!previous || call.updatedAt >= previous.updatedAt) - callsByKey.set(key, call); - } - for (const call of callsByKey.values()) { - ensure(call.turnId, `turn:${call.turnId}`).calls.push(call); - } - if (activeTurnId) ensure(activeTurnId, `turn:${activeTurnId}`); - - for (const row of rows.values()) { - row.items.sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)); - row.calls.sort( - (a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id), - ); - const assistants = row.messages.filter( - (message) => message.role === 'assistant', - ); - const rawAssistants = assistants.filter( - (message) => - message.messageId && !directConversationTurnId(message.messageId), - ); - const textIds = new Set( - row.items.filter((item) => item.kind === 'text').map((item) => item.id), - ); - // 精确 item id 才能补齐旧快照正文;无法证明流覆盖完整历史时,整轮使用历史, - // 不同时渲染“部分流 + 全文”,也不按长度比例猜测是否重复。 - const historyCovered = assistants.every( - (message) => - Boolean(directConversationTurnId(message.messageId)) || - Boolean( - message.messageId && - textIds.has(`text:${row.turnId}:${message.messageId}`), - ), - ); - row.source = - row.items.length > 0 && - historyCovered && - (textIds.size > 0 || assistants.length === 0) - ? 'stream' - : 'messages'; - if (row.source === 'stream') { - row.notices = assistants.filter( - (message) => - message.messageId === `direct-codex:${row.turnId}:failure` && - !textIds.has(`text:${row.turnId}:failure`), - ); - const historyById = new Map( - rawAssistants.map((message) => [ - `text:${row.turnId}:${message.messageId}`, - message, - ]), - ); - row.items = row.items.map((item) => { - const history = historyById.get(item.id); - return item.kind === 'text' && history - ? { ...item, text: projectSupervisorChatMessageText(history) } - : item; - }); - } - if (row.source === 'messages' && rawAssistants.length > 0) { - const lastText = row.items.filter((item) => item.kind === 'text').at(-1); - const lastRaw = rawAssistants.at(-1); - // 最后一个原始 item 与流尾身份一致时,GUI 整轮回执不是另一条回复。 - if ( - lastText && - lastRaw && - lastText.id === `text:${row.turnId}:${lastRaw.messageId}` - ) { - row.messages = row.messages.filter( - (message) => - message.messageId !== `direct-codex:${row.turnId}:assistant`, - ); +/** + * 条目 + 运行期本地消息 → 回合列表。 + * + * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 + * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + */ +export function buildDirectChatTurns({ + entries, + localMessages = [], + turnRunning = false, +}: { + entries: readonly DirectChatEntry[]; + localMessages?: readonly ChatMessage[]; + turnRunning?: boolean; +}): DirectChatTurn[] { + const turns: DirectChatTurnEntries[] = []; + let current: DirectChatTurnEntries | null = null; + // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, + // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 + const leadingEntries: DirectChatEntry[] = []; + for (const entry of entries) { + if (entry.kind === 'message' && entry.role === 'user') { + current = newTurn(entry.itemId); + turns.push(current); + if (leadingEntries.length > 0) { + current.entries.push(...leadingEntries); + leadingEntries.length = 0; } } - if (row.active && row.source === 'messages' && assistants.length === 0) { - row.transientReply = transientReply; + if (!current) { + leadingEntries.push(entry); + continue; } - const starts = [ - ...row.messages - .filter((message) => message.role === 'user') - .map((message) => directMessageTimestamp(message.updatedAt)), - ...row.items.map((item) => directMessageTimestamp(item.at)), - ...row.calls.map((call) => directMessageTimestamp(call.startedAt)), - ].filter((at) => at > 0); - row.startedAt = starts.length ? Math.min(...starts) : 0; - row.endedAt = Math.max( - 0, - ...row.messages.map((message) => - directMessageTimestamp(message.updatedAt), - ), - ...row.items.map((item) => directMessageTimestamp(item.updatedAt)), - ...row.calls.map((call) => directMessageTimestamp(call.updatedAt)), - ); + current.entries.push(entry); } - return [...rows.values()].filter( - (row) => - row.active || - visibleKeys.has(row.key) || - (!hasUnloadedHistory && - row.messages.length === 0 && - (row.items.length > 0 || row.calls.length > 0)), - ); + if (!current && leadingEntries.length > 0) { + // 整份历史都没有用户条目:仍然保留一个回合承载正文。 + current = newTurn(`history:${turns.length}`); + current.entries.push(...leadingEntries); + turns.push(current); + } + + const entryIds = new Set(entries.map((entry) => entry.itemId)); + localMessages.forEach((message, index) => { + if (message.messageId && entryIds.has(message.messageId)) return; + if (message.role === 'user') { + const block = blockFromLocalMessage(message, index); + current = newTurn(message.messageId ?? `local:${index}`); + turns.push(current); + if (block) current.localUsers.push(block); + return; + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.notices.push(message); + }); + if (current) current.active = turnRunning; + + return turns.map((turn) => { + const lastAssistant = turn.active + ? -1 + : turn.entries.reduce( + (found, entry, index) => + entry.kind === 'message' && entry.role === 'assistant' + ? index + : found, + -1, + ); + const users: DirectChatBlock[] = []; + const process: DirectChatBlock[] = []; + const finals: DirectChatBlock[] = []; + turn.entries.forEach((entry, index) => { + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); + if (!block) return; + if (block.kind === 'user') { + users.push(block); + return; + } + if (index === lastAssistant) { + finals.push(block); + return; + } + process.push(block); + }); + users.push(...turn.localUsers); + turn.notices.forEach((message, index) => { + const block = blockFromLocalMessage(message, index); + if (block) finals.push(block); + }); + const times = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ...users.map((block) => (block.kind === 'user' ? block.at : 0)), + ].filter((at) => at > 0); + const startedAt = times.length ? Math.min(...times) : 0; + const endedAt = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ].reduce((latest, at) => Math.max(latest, at), 0); + return { + key: turn.key, + users, + process: mergeToolBlocks(process), + finals, + active: turn.active, + startedAt, + endedAt, + } satisfies DirectChatTurn; + }); +} + +/** 条目里的用户 / 助手消息转聊天消息(独立窗口只渲染气泡,不渲染工具卡片)。 */ +export function directChatEntriesToMessages( + entries: readonly DirectChatEntry[], +): ChatMessage[] { + return entries.flatMap((entry): ChatMessage[] => { + if (entry.kind !== 'message') return []; + const text = entry.text ?? ''; + if (!text.trim()) return []; + return [ + { + role: entry.role === 'user' ? 'user' : 'assistant', + text, + runtimeOwned: true, + messageId: entry.itemId, + updatedAt: normalizeDirectTimestamp(entry.at), + }, + ]; + }); +} + +/** 同一 messageId 只留一条:条目版本在前,本地运行期消息只补空位。 */ +export function mergeDirectChatMessages( + leading: readonly ChatMessage[], + trailing: readonly ChatMessage[], +): ChatMessage[] { + const seen = new Set(); + const merged: ChatMessage[] = []; + for (const message of [...leading, ...trailing]) { + if (message.messageId) { + if (seen.has(message.messageId)) continue; + seen.add(message.messageId); + } + merged.push(message); + } + return merged; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts new file mode 100644 index 000000000..ea7e6e112 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadConsumeResult = { events: Array }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts new file mode 100644 index 000000000..215819e1a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 增量正文属于哪类条目。 + */ +export type DirectThreadDeltaKind = 'message' | 'reasoning'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts new file mode 100644 index 000000000..a4b0d7688 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +import type { DirectThreadItem } from './DirectThreadItem'; +import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; + +/** + * Thread Manager 下发的运行态事件。 + * + * 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: + * 游标是 Thread Manager 的内部事实,不下发。 + * + * 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 + * 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 + */ +export type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { + type: 'item.delta'; + itemId: string; + kind: DirectThreadDeltaKind; + delta: string; + } + | { + type: 'request'; + kind: DirectThreadRequestKind; + requestId: string | null; + }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts new file mode 100644 index 000000000..a510ccb68 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条文件变更。 + */ +export type DirectThreadFileChange = { + path: string; + /** + * `add` | `update` | `delete` + */ + kind: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts new file mode 100644 index 000000000..9ac30f139 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadItem } from './DirectThreadItem'; + +export type DirectThreadHistorySlice = { + /** + * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + */ + items: Array; + hasMore: boolean; + /** + * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + */ + firstItemId: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts new file mode 100644 index 000000000..f1257ddcc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts @@ -0,0 +1,76 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadFileChange } from './DirectThreadFileChange'; + +/** + * 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 + * + * `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 + * 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 + * + * 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, + * 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 + */ +export type DirectThreadItem = + | { + itemType: 'message'; + /** + * 归一身份:全链路只有这一个 id。 + */ + itemId: string; + /** + * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + */ + role: string; + text: string; + at: number; + } + | { itemType: 'reasoning'; itemId: string; text: string; at: number } + | { + itemType: 'function_call'; + itemId: string; + name: string; + arguments: string; + at: number; + } + | { + itemType: 'function_call_output'; + itemId: string; + output: string; + at: number; + } + | { + itemType: 'commandExecution'; + itemId: string; + command: string; + output: string | null; + /** + * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + */ + status: string | null; + exitCode: number | null; + at: number; + } + | { + itemType: 'fileChange'; + itemId: string; + changes: Array; + at: number; + } + | { + itemType: 'mcpToolCall'; + itemId: string; + tool: string; + arguments: string; + output: string | null; + status: string | null; + at: number; + } + | { + itemType: 'webSearch'; + itemId: string; + query: string | null; + output: string | null; + at: number; + } + | { itemType: 'contextCompaction'; itemId: string; at: number } + | { itemType: 'other'; itemId: string; rawType: string; at: number }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts new file mode 100644 index 000000000..13ee401f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 + */ +export type DirectThreadRequestKind = + | 'approval.requested' + | 'ask.requested' + | 'request.resolved'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts new file mode 100644 index 000000000..45efbf4b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadSubscriptionBootstrap = { + subscriptionId: string; + /** + * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + */ + lastCompletedItemId: string | null; + /** + * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + */ + events: Array; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aeeebc8f8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +export type { DirectThreadEvent } from './DirectThreadEvent'; +export type { DirectThreadFileChange } from './DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; +export type { DirectThreadItem } from './DirectThreadItem'; +export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..e41ca0354 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,5 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial< /** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], + calls: DirectChatToolCard[], running = false, ) { const counts = new Map(); @@ -77,7 +75,7 @@ export function toolCallGroupSummary( } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) { * 这两种情况不显示耗时,不显示 `0s` / 负数。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; @@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) { /** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ export function turnToolCallDurationMs( - calls: Array>, + calls: Array>, ): number | null { let minStartedAt = Number.POSITIVE_INFINITY; let maxUpdatedAt = Number.NEGATIVE_INFINITY; @@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) { /** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ export function turnToolCallEndedAt( - calls: Array>, + calls: Array>, ) { let maxUpdatedAt = 0; for (const call of calls) { @@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) { * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 */ export function turnToolCallTimeLabel( - calls: Array>, + calls: Array>, userSentAt: number | null | undefined, ) { const endLabel = formatClockTime(turnToolCallEndedAt(calls)); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..d299cdd55 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) { } /** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */ -async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { +async function openDirectCodexSurface( + overrides: InvokeOverrides = {}, + beforeOpen?: ( + harness: ReturnType, + ) => void, +) { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath: DYNAMIC_GAME_PROJECT_PATH, initialSessionExists: false, }); + beforeOpen?.(supervisorHarness); const manifest = createGameCreationAppManifest( 'local-project-draft', '输入盒控件项目', @@ -110,7 +116,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); - return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; + return { + invoke, + path: DYNAMIC_GAME_PROJECT_PATH, + surface, + harness: supervisorHarness, + }; } async function submitDirectTurn( @@ -474,14 +485,69 @@ export function registerChatComposerControlTests() { }); }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, surface, harness } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }, + (directHarness) => { + directHarness.emitDirectThreadEvents({ type: 'turn.started' }); + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + + await setComposerText(composer, '恢复后排队的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(0); + + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '恢复后排队的消息' }), + ); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); + + await act(async () => { + pending[0]?.resolve('排队回合回复'); + }); + }); + it('terminates the running turn and returns the composer to the idle state', async () => { const pending: Array<{ resolve: (value: string) => void; reject: (error: Error) => void; }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ + const { invoke, path, surface, harness } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve, reject) => { + // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 + harness.emitDirectThreadEvents({ type: 'turn.started' }); pending.push({ resolve, reject }); }), cancel_direct_codex_turn: async () => undefined, @@ -502,13 +568,16 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { projectPath: path, - clientTurnId: expect.any(String), }); }); // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 act(() => { pending[0]?.reject(new Error('Codex app-server turn 已中断')); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'interrupted', + }); }); await waitFor(() => { const send = within(surface).getByRole('button', { name: '发送' }); @@ -518,6 +587,43 @@ export function registerChatComposerControlTests() { expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); }); + it('terminates a turn that was submitted before its lifecycle event arrived', async () => { + // 刚提交、`turn.started` 还没下发:按钮已经变成「终止」,点击不能回"没有正在运行的回合"。 + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + cancel_direct_codex_turn: async () => undefined, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '做一个小游戏'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '做一个小游戏' }), + ); + }); + + const stopButton = await within(surface).findByRole('button', { + name: '终止', + }); + fireEvent.click(stopButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { + projectPath: path, + }); + }); + expect( + within(surface).queryByText('当前没有正在运行的回合,无法终止。'), + ).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回合回复'); + }); + }); + it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5dbedafbf..98436f59b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -627,6 +627,23 @@ function createPlanGddStateView( }; } +/** + * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 + * + * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 + */ +function directThreadEventItemId( + event: Record, +): string | null { + if (event.type !== 'item.started' && event.type !== 'item.completed') { + return null; + } + const item = event.item; + if (!item || typeof item !== 'object') return null; + const itemId = (item as Record).itemId; + return typeof itemId === 'string' && itemId ? itemId : null; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', @@ -744,6 +761,31 @@ function createProjectSupervisorRuntimeHarness({ let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; + let directThreadNotifyHandler: + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; + let directThreadSubscriptionId: string | null = null; + let directThreadSubscriptionSequence = 0; + // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, + // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 + let pendingDirectThreadEvents: Array> = []; + let directThreadHistoryItems: Array> = []; + let directThreadLastCompletedItemId: string | null = null; + // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 + // 锚点一致,重复 `turn.started` 在生产里不会出现。 + let directThreadTurnRunning = false; + // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" + // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 + const directThreadActiveItemIds = new Set(); + const directThreadPendingEventSeqs = new Map< + Record, + number + >(); + let directThreadEventSequence = 0; + let directThreadLifecycleAnchor: { + seq: number; + event: Record; + } | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -938,6 +980,93 @@ function createProjectSupervisorRuntimeHarness({ const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } + if (command === 'subscribe_direct_project_thread') { + directThreadSubscriptionSequence += 1; + directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; + const pending = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, + // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 + const replay = pending + .filter((event) => { + const itemId = directThreadEventItemId(event); + return Boolean(itemId && directThreadActiveItemIds.has(itemId)); + }) + .map((event) => ({ + seq: directThreadPendingEventSeqs.get(event) ?? 0, + event, + })); + for (const event of pending) { + directThreadPendingEventSeqs.delete(event); + } + if ( + directThreadLifecycleAnchor && + !replay.some( + (entry) => entry.event === directThreadLifecycleAnchor?.event, + ) + ) { + replay.push(directThreadLifecycleAnchor); + } + replay.sort((left, right) => left.seq - right.seq); + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events: replay.map((entry) => entry.event), + }; + } + if (command === 'consume_direct_project_thread') { + if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { + throw new Error('SUBSCRIPTION_EXPIRED'); + } + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { events }; + } + if (command === 'read_direct_project_history_slice') { + // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, + // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 + // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + const requestedLimit = Number(args?.limit ?? 20); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), + 200, + ); + const throughItemId = + typeof args?.throughItemId === 'string' && args.throughItemId + ? args.throughItemId + : null; + const beforeItemId = + typeof args?.beforeItemId === 'string' && args.beforeItemId + ? args.beforeItemId + : null; + let end = directThreadHistoryItems.length; + if (throughItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === throughItemId, + ); + if (anchorIndex < 0) { + throw new Error( + `DirectProject 历史中不存在 item:${throughItemId}`, + ); + } + end = anchorIndex + 1; + } else if (beforeItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === beforeItemId, + ); + end = + anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; + } + const start = Math.max(0, end - limit); + const window = directThreadHistoryItems.slice(start, end); + const oldest = window[0]; + return { + items: [...window], + hasMore: start > 0, + firstItemId: + oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -1120,6 +1249,10 @@ function createProjectSupervisorRuntimeHarness({ designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } + if (eventName === 'game-creator-direct-thread-notify') { + directThreadNotifyHandler = + handler as unknown as typeof directThreadNotifyHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1133,10 +1266,45 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + for (const event of events) { + directThreadEventSequence += 1; + directThreadPendingEventSeqs.set(event, directThreadEventSequence); + if (event.type === 'turn.started') directThreadTurnRunning = true; + if (event.type === 'turn.completed') directThreadTurnRunning = false; + if (event.type === 'turn.started' || event.type === 'turn.completed') { + // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 + directThreadLifecycleAnchor = { + seq: directThreadEventSequence, + event, + }; + } + const itemId = directThreadEventItemId(event); + if (itemId && event.type === 'item.started') { + directThreadActiveItemIds.add(itemId); + } + if (itemId && event.type === 'item.completed') { + directThreadActiveItemIds.delete(itemId); + } + } + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1229,6 +1397,67 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 + const events: Array> = directThreadTurnRunning + ? [] + : [{ type: 'turn.started' }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:assistant`, + role: 'assistant', + text: reply, + at: at + 1, + }, + }); + directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; + events.push({ type: 'turn.completed', status }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + // 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示 + // 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。 + const newest = directThreadHistoryItems.at(-1); + directThreadLastCompletedItemId = + typeof newest?.itemId === 'string' ? newest.itemId : null; + }, + /** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */ + setDirectThreadLastCompletedItemId(itemId: string | null) { + directThreadLastCompletedItemId = itemId; + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 475242de0..b327549b4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1553,6 +1553,11 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '你好,今天多少号', + reply: '别这么骂自己,具体发生什么了?', + }); return '别这么骂自己,具体发生什么了?'; } return supervisorHarness.invoke(command, args); @@ -1690,6 +1695,11 @@ export function registerHomeProjectCreationTests() { return '角色参考游戏'; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '按这个角色做游戏', + reply: '附件已经进入当前项目。', + }); return '附件已经进入当前项目。'; } return supervisorHarness.invoke(command, args); @@ -2520,8 +2530,12 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); - return { items: [...persistedMessages], hasMore: false }; + expect(args).toEqual({ projectPath, limit: 20 }); + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2530,14 +2544,22 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push(args?.userItem as Record, { - role: 'assistant', - type: 'message', - content: [ - { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, - ], - id: `direct-codex:${clientTurnId}:assistant`, - }); + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '继续修改已有项目', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: 'DIRECT_EXISTING_PROJECT_OK', + at: 9_001, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2606,8 +2628,12 @@ export function registerHomeProjectCreationTests() { return manifest; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); - return { items: [...persistedMessages], hasMore: false }; + expect(args).toEqual({ projectPath, limit: 20 }); + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_permission_log') { return {}; @@ -2631,17 +2657,23 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - persistedMessages.push(args?.userItem as Record, { - id: `direct-codex:${clientTurnId}:assistant`, - type: 'message', - role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], - }); + // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '生成一个游戏', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2666,21 +2698,18 @@ export function registerHomeProjectCreationTests() { }); expect(persistedMessages).toEqual([ { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), role: 'user', - content: [{ type: 'input_text', text: '生成一个游戏' }], + text: '生成一个游戏', + at: 9_000, }, { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, }, ]); expect(JSON.stringify(persistedMessages)).not.toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 18bda4813..1d12fb99a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7323,6 +7323,11 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '从旧任务状态继续生成,但使用 direct Codex', + reply: 'DIRECT_AFTER_RECONCILIATION_OK', + }); return 'DIRECT_AFTER_RECONCILIATION_OK'; } if (command === 'get_local_game_preview_status') { @@ -7717,7 +7722,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it.skip('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { + it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -7726,28 +7731,9 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); - }, - ); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -7766,20 +7752,28 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { - return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; + } + if (command === 'get_local_game_preview_status') { + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + return { status: 'stopped', url: null, port: null, root: null }; } return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // DirectProject 拥有自己的会话身份:打开项目不得触碰老 Supervisor 会话与运行态。 expect( within(supervisorSurface).queryByText('已恢复的项目总控历史'), ).toBeNull(); @@ -7796,10 +7790,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toBe(false); expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); - expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull(); expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect(screen.queryByText('严格审批')).toBeNull(); - expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -7810,53 +7802,31 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const firstDirectReply = createDeferred(); - const secondDirectReply = createDeferred(); - let directReplyCount = 0; - invoke.mockImplementation( - async (command: string, args?: Record) => { - if (command === 'chat_with_game_creator_direct_codex') { - directReplyCount += 1; - return directReplyCount === 1 - ? firstDirectReply.promise - : secondDirectReply.promise; - } - return supervisorHarness.invoke(command, args); - }, - ); - await setComposerText( screen.getByLabelText('陶泥儿对话内容'), '先完成正式客户端玩法拆解', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); - await waitFor(() => - expect( - within( - within(supervisorSurface).getByTestId('agent-tool-call-group'), - ).getByText('需求已接收'), - ).not.toBeNull(), - ); - const directMessageList = - within(supervisorSurface).getByLabelText('陶泥儿消息'); - Object.defineProperties(directMessageList, { - clientHeight: { configurable: true, value: 180 }, - scrollHeight: { configurable: true, value: 640 }, - scrollTop: { configurable: true, value: 0, writable: true }, + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, + }, + ); }); - const waitingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(waitingProcessCard.parentElement).toBe(directMessageList); - expect( - within(waitingProcessCard).getByText('正在等待陶泥儿开始'), - ).not.toBeNull(); - expect( - within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在等待陶泥儿开始'); const firstDirectCall = invoke.mock.calls.find( ([command]) => command === 'chat_with_game_creator_direct_codex', ); @@ -7865,223 +7835,16 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(firstTurnId).not.toBe(''); - expect(firstDirectCall?.[1]).toEqual({ - projectPath, - prompt: '先完成正式客户端玩法拆解', - clientTurnId: firstTurnId, - userItem: { - id: `direct-codex:${firstTurnId}:user`, - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], - }, - }); - await act(async () => { - supervisorHarness.emitProgress('direct', '正在准备安全阶段'); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 0, - status: 'accepted', - activity: 'request-accepted', - updatedAt: 1000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'running', - activity: 'preparing', - updatedAt: 1200, - }, - }); - }); - const thinkingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在思考中'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 2, - status: 'running', - activity: 'file-read', - accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`, - updatedAt: 1500, - }, - }); - }); - const runningProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ).not.toBeNull(); - fireEvent.click( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 3, - status: 'streaming', - activity: 'controlled-tool', - accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', - updatedAt: 2000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'streaming', - activity: 'file-write', - accumulatedText: '乱序事件不能回退正文', - updatedAt: 1500, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: 'old-direct-turn', - sequence: 99, - status: 'streaming', - activity: 'validation', - accumulatedText: '旧回合不能覆盖正文', - updatedAt: 3000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath: '/tmp/wrong-direct-project', - turnId: firstTurnId, - sequence: 100, - status: 'streaming', - activity: 'response-finalization', - accumulatedText: '错误项目不能覆盖正文', - updatedAt: 4000, - }, - }); - supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); - }); - const streamingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect(directMessageList.scrollTop).toBe(640); - expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); - expect( - within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), - ).toBeNull(); - directMessageList.scrollTop = 100; - fireEvent.scroll(directMessageList); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 4, - status: 'running', - accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`, - updatedAt: 4500, - }, - }); - }); - expect(directMessageList.scrollTop).toBe(100); - expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByText(/正在执行 npm test/u), - ).not.toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 5, - status: 'running', - activity: 'command-exec', - accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, - updatedAt: 5000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 6, - status: 'running', - activity: 'command-exec', - updatedAt: 5100, - }, - }); - }); - expect( - within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 7, - status: 'running', - activity: 'controlled-tool', - accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, - updatedAt: 5200, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 8, - status: 'running', - activity: 'controlled-tool', - updatedAt: 5300, - }, - }); - }); - expect( - within(streamingProcessCard).getByText( - /正在写入文件:game\/index\.html/u, - ), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect( - ( - within(streamingProcessCard).getByRole('button', { - name: '收起', - }) as HTMLButtonElement - ).getAttribute('aria-expanded'), - ).toBe('true'); await act(async () => { firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + supervisorHarness.completeDirectThreadTurn({ + turnId: firstTurnId, + prompt: '先完成正式客户端玩法拆解', + reply: 'DIRECT_REPLY:先完成正式客户端玩法拆解', + }); }); + // 用户条目由落盘事件回填:乐观气泡与事件条目同身份,只渲染一次。 await waitFor(() => { expect( within(supervisorSurface).getAllByText( @@ -8089,16 +7852,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toHaveLength(1); expect( - within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'), - ).toBeNull(); - expect( - within(supervisorSurface).queryByText( - 'DIRECT_STREAM:先完成正式客户端玩法拆解', - ), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); + within(supervisorSurface).getAllByText('先完成正式客户端玩法拆解'), + ).toHaveLength(1); }); await waitFor(() => { expect( @@ -8114,8 +7869,10 @@ export function registerProjectSupervisorSurfaceTests() { screen.getByLabelText('陶泥儿对话内容'), '补充:优先复用现有素材', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -8141,68 +7898,12 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(secondTurnId).not.toBe(firstTurnId); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 0, - status: 'streaming', - activity: 'not-a-public-activity', - accumulatedText: '即将失败的临时正文', - updatedAt: 5000, - }, - }); - }); - const failedTurnProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedTurnProcessCard).getByText('回复生成中'), - ).not.toBeNull(); - expect( - within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('即将失败的临时正文'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 1, - status: 'failed', - activity: 'none', - accumulatedText: '失败事件不得保留这段正文', - updatedAt: 5100, - }, - }); - }); - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - const failedStatusProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedStatusProcessCard).getByText('处理失败'), - ).not.toBeNull(); - expect( - within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在记录失败原因'); + + // 失败回合:运行期说明只留在界面,不进历史;输入盒必须回到可用态。 await act(async () => { secondDirectReply.reject(new Error('模拟 direct 失败')); }); await waitFor(() => { - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); expect( ( within(supervisorSurface).getByRole('button', { @@ -8210,6 +7911,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8226,8 +7930,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); - // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 - // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 + // 首次进入项目时核验一次预览活体;开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', @@ -8245,7 +7948,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it.skip('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from thread events and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8254,28 +7957,23 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); + // 打开项目时已有上一轮对话:这一轮的块必须落在消息流末尾,而不是被锚到别人头上。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:user', + role: 'user', + text: '先做一个主菜单', + at: 900, }, - ); + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:assistant', + role: 'assistant', + text: '上一轮已完成', + at: 1000, + }, + ]); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8297,35 +7995,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 打开项目时有一轮已落盘的对话:工具调用卡要挂在它的 assistant 消息之后。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个跑酷游戏', - agentId: null, - messageId: 'direct-codex:turn-existing:user', - updatedAt: 900, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮已完成', - agentId: null, - messageId: 'direct-codex:turn-existing:assistant', - updatedAt: 1000, - }, - ], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8334,7 +8003,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8370,31 +8039,33 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1000, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1000, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 995, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8413,52 +8084,49 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 expect(runningHead.textContent).toContain('1 个命令'); + // 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 2, - status: 'running', - activity: 'file-write', - updatedAt: 1100, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build', output: 'build ok' }, - startedAt: 1000, - updatedAt: 1100, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-file', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'failed', - detail: { - changes: [ - { path: 'game/src/hero.ts', kind: 'update' }, - { path: 'game/src/hero.ts', kind: 'delete' }, - ], - }, - startedAt: 1050, - updatedAt: 1100, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: 1100, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1050, + }, + }, + { + type: 'item.completed', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8467,18 +8135,11 @@ export function registerProjectSupervisorSurfaceTests() { const group = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', )[0] as HTMLElement; - expect(group.getAttribute('data-status')).toBe('failed'); const groupHead = within(group).getByTestId('agent-tool-call-group-head'); expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 expect(group.getAttribute('data-duration-ms')).toBe('100'); - expect(groupHead.textContent).toContain('用时 1秒'); - // 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。 - expect( - groupHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); - // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, - // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8489,29 +8150,23 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(groupHead.getAttribute('aria-expanded')).toBe('true'); }); - const groupBody = group.querySelector( - `#${groupHead.getAttribute('aria-controls')}`, - ); - expect(groupBody?.hasAttribute('hidden')).toBe(false); const rows = within(group).getAllByTestId('agent-tool-call-row'); expect(rows).toHaveLength(2); expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([ 'command', 'file_change', ]); - // 同一 id 只渲染一次,状态由 completed 覆盖;failed 行标「失败」。 - expect(rows[0]?.getAttribute('data-status')).toBe('completed'); - expect(rows[1]?.getAttribute('data-status')).toBe('failed'); const commandRow = rows[0] as HTMLElement; const fileRow = rows[1] as HTMLElement; - expect(within(commandRow).getByText('npm run build')).not.toBeNull(); - expect(within(fileRow).getByText('game/src/hero.ts')).not.toBeNull(); - expect(within(fileRow).getByText('失败')).not.toBeNull(); + expect( + within(commandRow).getAllByText('npm run build').length, + ).toBeGreaterThan(0); + expect( + within(fileRow).getAllByText('game/src/hero.ts').length, + ).toBeGreaterThan(0); // 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。 expect(commandRow.getAttribute('data-duration-ms')).toBe('100'); - expect(within(commandRow).getByText('0.1s')).not.toBeNull(); expect(fileRow.getAttribute('data-duration-ms')).toBe('50'); - expect(within(fileRow).getByText('0.1s')).not.toBeNull(); // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 const commandRowHead = within(commandRow).getByRole('button'); @@ -8545,10 +8200,14 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(fileDetail as HTMLElement).getByText('修改')).not.toBeNull(); expect(within(fileDetail as HTMLElement).getByText('删除')).not.toBeNull(); - // 回合结束:assistant 消息落盘后,块移到该回合 assistant 消息**之前**(工具在上、答复在下), - // 不重复、不消失。 + // 回合结束:正文进历史,工具块收进「执行过程」折叠区,不重复、不消失。 await act(async () => { directReply.resolve('DIRECT_REPLY:做一个跑酷游戏'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:做一个跑酷游戏', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8564,24 +8223,12 @@ export function registerProjectSupervisorSurfaceTests() { const settledGroups = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', ); - const children = Array.from((messageList as HTMLElement).children); - const assistantIndex = children.findIndex( - (node) => - node.classList.contains('message--assistant') && - node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(settledGroups[0] ?? null)).toBe(true); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], - ); - expect(settledGroupIndex).toBeGreaterThanOrEqual(0); - // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 - expect(settledGroupIndex).toBeLessThan(assistantIndex); - expect(settledGroupIndex).toBe(assistantIndex - 1); - expect( - children[settledGroupIndex - 1]?.classList.contains('message--user'), - ).toBe(true); - // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 const settledGroup = settledGroups[0] as HTMLElement; @@ -8610,8 +8257,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(settledBody?.hasAttribute('hidden')).toBe(true); }); }); - - it.skip('reads the persisted tool-call history whenever a project is opened', async () => { + it('renders persisted tool cards from the history slice whenever a project is opened', async () => { const projectPath = '/tmp/launcher-tool-call-persisted-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8620,7 +8266,42 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let readToolCallCount = 0; + // 历史切片的条目已经是投影形状:工具的调用与输出共用同一个 itemId。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:user', + role: 'user', + text: '做一个小球弹跳游戏', + at: 1000, + }, + { + itemType: 'function_call', + itemId: 'persisted-command', + name: 'exec_command', + arguments: 'npm run build', + at: 1000, + }, + { + itemType: 'function_call_output', + itemId: 'persisted-command', + output: 'built in 500ms', + at: 1500, + }, + { + itemType: 'fileChange', + itemId: 'persisted-file', + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + at: 1500, + }, + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:assistant', + role: 'assistant', + text: '上一轮的答复', + at: 2000, + }, + ]); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -8638,61 +8319,98 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + // 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。 + throughItemId: 'direct-codex:turn-persisted:assistant', + limit: 20, + }); + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 + const persistedGroups = await within(supervisorSurface).findAllByTestId( + 'agent-tool-call-group', + ); + expect(persistedGroups).toHaveLength(1); + const persistedGroup = persistedGroups[0] as HTMLElement; + const persistedHead = within(persistedGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); + expect(persistedHead.textContent).toContain( + '已执行 1 个命令、1 个文件变更', + ); + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(persistedGroup)).toBe(true); + expect(processSection.textContent).not.toContain('上一轮的答复'); + fireEvent.click(persistedHead); + const persistedRows = within(persistedGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(persistedRows).toHaveLength(2); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 + expect( + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); + }); + + it('loads the earlier direct history page from the first-item anchor', async () => { + // 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。 + const projectPath = '/tmp/launcher-direct-history-pagination-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-pagination-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。 + supervisorHarness.setDirectThreadHistory( + Array.from({ length: 26 }, (_, index) => { + const label = `历史对话 ${String(index + 1).padStart(2, '0')}`; + return { + itemType: 'message', + itemId: `direct-codex:turn-${label}:user`, + role: 'user', + text: label, + at: 1000 + index, + }; + }), + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个小球弹跳游戏', - agentId: null, - messageId: 'direct-codex:turn-persisted:user', - updatedAt: 1000, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮的答复', - agentId: null, - messageId: 'direct-codex:turn-persisted:assistant', - updatedAt: 2000, - }, - ], + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-pagination-game', + recentRunStatus: null, + recentRunStopReason: null, }; } - if (command === 'read_direct_tool_calls') { - readToolCallCount += 1; - return [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-command', - turnId: 'turn-persisted', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1500, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-file', - turnId: 'turn-persisted', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'completed', - detail: { - changes: [{ path: 'game/src/hero.ts', kind: 'add' }], - }, - startedAt: 1500, - updatedAt: 2000, - }, - ]; + if (command === 'get_local_game_manifest') { + return manifest; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; @@ -8709,59 +8427,333 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { - projectPath, - }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 - const persistedGroups = await within(supervisorSurface).findAllByTestId( - 'agent-tool-call-group', - ); - expect(persistedGroups).toHaveLength(1); - const persistedGroup = persistedGroups[0] as HTMLElement; - const persistedHead = within(persistedGroup).getByTestId( - 'agent-tool-call-group-head', - ); - expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); - expect(persistedHead.textContent).toContain( - '已执行 1 个命令、1 个文件变更', - ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000'); - expect(persistedHead.textContent).toContain('用时 1秒'); expect( - persistedHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/); - const messageList = supervisorSurface.querySelector( - '.project-supervisor-message-list', - ) as HTMLElement; - const children = Array.from(messageList.children); - const assistantIndex = children.findIndex((node) => - node.classList.contains('message--assistant'), - ); - const groupIndex = children.findIndex((node) => node === persistedGroup); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - expect(groupIndex).toBe(assistantIndex - 1); - fireEvent.click(persistedHead); - const persistedRows = within(persistedGroup).getAllByTestId( - 'agent-tool-call-row', - ); - expect(persistedRows).toHaveLength(2); - // 每行右侧是该工具自己的耗时(0.5s / 0.5s)。 - expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500'); - expect( - within(persistedRows[0] as HTMLElement).getByText('0.5s'), + await within(supervisorSurface).findByText('历史对话 26'), ).not.toBeNull(); + // 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。 + expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'direct-codex:turn-历史对话 26:user', + limit: 20, + }); + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。 + expect( + await within(supervisorSurface).findByText('历史对话 01'), + ).not.toBeNull(); + expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'direct-codex:turn-历史对话 07:user', + limit: 20, + }); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('anchors the first history page at the subscribe receipt instead of the file tail', async () => { + // 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才 + // 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。 + const projectPath = '/tmp/launcher-direct-history-anchor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-anchor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'turn-1-user', + role: 'user', + text: '历史对话 01', + at: 1000, + }, + { + itemType: 'message', + itemId: 'turn-2-user', + role: 'user', + text: '历史对话 02', + at: 1100, + }, + // 订阅回执之后才落盘的两条:只应从运行态事件来。 + { + itemType: 'function_call', + itemId: 'process-1', + name: 'exec_command', + arguments: 'npm run build', + at: 1200, + }, + { + itemType: 'message', + itemId: 'turn-3-user', + role: 'user', + text: '历史对话 03', + at: 1300, + }, + ]); + supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-anchor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'turn-2-user', + limit: 20, + }), + ); + // 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。 + expect( + await within(supervisorSurface).findByText('历史对话 02'), + ).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull(); + // 锚点只能来自订阅回执:回执必须先于首屏读取发生。 + const commands = invoke.mock.calls.map(([command]) => command); + expect( + commands.indexOf('subscribe_direct_project_thread'), + ).toBeGreaterThanOrEqual(0); + expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan( + commands.indexOf('read_direct_project_history_slice'), + ); + }); + + it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => { + // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, + // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 + const projectPath = '/tmp/launcher-direct-history-cross-page-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-cross-page-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50), + // 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-更早:user', + role: 'user', + text: '更早的用户提问', + at: 1000, + }, + ...Array.from({ length: 50 }, (_, index) => ({ + itemType: 'function_call', + itemId: `process-${index + 1}`, + name: 'exec_command', + arguments: `echo ${index + 1}`, + at: 1100 + index, + })), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-cross-page-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'process-50', + limit: 20, + }), + ); + // 首屏只有这个回合的执行过程:用户气泡还在更早的页里。 + expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull(); + + // 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。 + const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + messageList.scrollTop = 100; + fireEvent.scroll(messageList); + // 插入后列表会变长:跟随最新的话视口会被拉到这个高度。 + messageScrollHeight = 2000; + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。 + expect( + await within(supervisorSurface).findByText('更早的用户提问'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-31', + limit: 20, + }); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-11', + limit: 20, + }); + // 取到文件头后按钮收起。 + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + // 连拉插入的旧内容不得把视口弹到列表底部。 + expect(messageList.scrollTop).toBe(100); + }); + + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { + // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 + // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 + const projectPath = '/tmp/launcher-notify-before-subscribe-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-notify-before-subscribe-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 订阅回执被挂住:Rust 侧已经建好订阅(因此事件会带通知),前端还在等回执。 + const subscribeReceipt = createDeferred(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-notify-before-subscribe-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'subscribe_direct_project_thread') { + const result = await supervisorHarness.invoke(command, args); + await subscribeReceipt.promise; + return result; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath, + }); + }); + + // 回执还没回到前端,整轮事件已经到了:通知此刻拿不到 subscriptionId,只能记账。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'direct-codex:notify-window:assistant', + role: 'assistant', + text: '回执前就到达的回复', + at: 900, + }, + }, + { type: 'turn.completed', status: 'completed' }, + ); + }); + expect( + within(supervisorSurface).queryByText('回执前就到达的回复'), + ).toBeNull(); + + // 回执到达:前端补一次 consume,把队列里的事件取回来。 + await act(async () => { + subscribeReceipt.resolve(); + }); + expect( + await within(supervisorSurface).findByText('回执前就到达的回复'), + ).not.toBeNull(); + // 补取之后不再有残留的忙碌态。 + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); + }); + + it('renders the running turn tool-call group in a fresh chat from the thread subscription', async () => { // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`描述你的想法,或 @ 引用素材`,没有 messageId)。 - // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, - // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + // 运行中的回合没有任何落盘正文,工具块必须靠订阅事件直接出现在消息列表末尾, + // 而不是等到回合结束、历史切片回来之后才出现。 const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8770,25 +8762,6 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen(eventName, handler); - }, - ); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8810,18 +8783,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8830,7 +8791,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8870,29 +8831,32 @@ export function registerProjectSupervisorSurfaceTests() { expect(clientTurnId).not.toBe(''); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1400, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'fresh-turn-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1400, - }, - ], + // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 900, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8902,7 +8866,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(runningGroups).toHaveLength(1); const runningGroup = runningGroups[0] as HTMLElement; expect(runningGroup.getAttribute('data-status')).toBe('running'); - expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); const runningHead = within(runningGroup).getByTestId( 'agent-tool-call-group-head', ); @@ -8918,14 +8881,51 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningRows).toHaveLength(1); expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); - expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + + // 同一条命令完成:同一个 itemId 就地更新,不新起一张卡,耗时按 startedAt → updatedAt 计算。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents({ + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: 1400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + fireEvent.click( + within(settledRunningGroup).getByTestId('agent-tool-call-group-head'), + ); + const settledRunningRows = within(settledRunningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(settledRunningRows).toHaveLength(1); + expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); expect( - within(runningRows[0] as HTMLElement).getByText('0.4s'), + within(settledRunningRows[0] as HTMLElement).getByText('0.4s'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8956,9 +8956,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, @@ -9152,7 +9151,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, - messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 diff --git a/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts new file mode 100644 index 000000000..524af7152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + directHistoryAnchorGateToWaitFor, + openDirectHistoryAnchorGate, + reuseOrOpenDirectHistoryAnchorGate, +} from '../src/features/project-workspace/directHistoryAnchorGate'; + +const projectA = '/tmp/项目A'; +const projectB = '/tmp/项目B'; + +/** 订阅侧的回执:一次订阅只 settle 一次。 */ +function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) { + gate.settle('turn-2-user'); +} + +describe('openDirectHistoryAnchorGate', () => { + it('默认不解析:回执到达前首屏必须等它', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + const settled = { value: 'pending' as string | null | 'pending' }; + void gate.anchor.then((anchor) => { + settled.value = anchor; + }); + + // 让微任务跑一轮:没有回执就不该有结果。 + await Promise.resolve(); + await Promise.resolve(); + expect(settled.value).toBe('pending'); + expect(gate.projectPath).toBe(projectA); + expect(gate.consumed).toBe(false); + }); + + it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(gate); + + await expect(gate.anchor).resolves.toBe('turn-2-user'); + }); + + it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + gate.settle(null); + + await expect(gate.anchor).resolves.toBeNull(); + }); +}); + +describe('reuseOrOpenDirectHistoryAnchorGate', () => { + it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => { + const opened = openDirectHistoryAnchorGate(projectA); + + expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened); + }); + + it('换项目开一道新闸门,不复用旧项目的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB); + + expect(next).not.toBe(opened); + expect(next.projectPath).toBe(projectB); + expect(next.consumed).toBe(false); + }); + + it('还没有闸门时新开一道', () => { + expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe( + projectA, + ); + }); +}); + +describe('directHistoryAnchorGateToWaitFor', () => { + it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => { + const gate = directHistoryAnchorGateToWaitFor(null, projectA); + + expect(gate).not.toBeNull(); + expect(gate?.projectPath).toBe(projectA); + subscribeReceipt(gate!); + await expect(gate!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => { + const opened = openDirectHistoryAnchorGate(projectA); + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA); + + expect(toWaitFor).toBe(opened); + subscribeReceipt(opened); + await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => { + const opened = openDirectHistoryAnchorGate(projectA); + opened.consumed = true; + + expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull(); + }); + + it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(opened); + opened.consumed = true; + + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB); + + expect(toWaitFor).not.toBeNull(); + expect(toWaitFor).not.toBe(opened); + expect(toWaitFor?.projectPath).toBe(projectB); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx deleted file mode 100644 index 8c0f00a19..000000000 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/** @vitest-environment jsdom */ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents'; -import { - act, - App, - createGameCreationAppManifest, - fireEvent, - React, - render, - screen, - setComposerText, -} from './appSurface/harness'; - -const projectPath = '/tmp/direct-message-pages'; -const manifest = createGameCreationAppManifest( - 'direct-message-pages', - '历史分页', -); -const messages = Array.from({ length: 26 }, (_, index) => ({ - id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`, - type: 'message', - role: index % 2 ? 'assistant' : 'user', - content: [ - { - type: index % 2 ? 'output_text' : 'input_text', - text: `历史正文 ${index}`, - }, - ], -})); -function page( - items: typeof messages, - hasMore: boolean, -): DirectThreadHistorySlice { - return { items, hasMore, oldestItemId: items[0]?.id ?? null }; -} -function deferred() { - let resolve!: (value: DirectThreadHistorySlice) => void; - let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -function install( - read: ( - args: Record, - ) => DirectThreadHistorySlice | Promise, -) { - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'read_direct_project_history_slice') { - expect(args?.messagesOnly).toBe(true); - return read(args!); - } - if (command === 'read_project_permission_policy') - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - if (command === 'get_local_game_manifest') return manifest; - if (command === 'read_game_creator_app_config') - return { - config: { - selectedModelId: 'quality', - selectedModelIsDefault: true, - llm: { customEnabled: false }, - }, - }; - if ( - command === 'read_direct_tool_calls' || - command === 'read_direct_turn_stream' || - command === 'list_game_creator_direct_active_turns' - ) - return []; - return null; - }, - ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; - return invoke; -} -function mount(path = projectPath) { - return render( - , - ); -} -const earlier = () => screen.getByRole('button', { name: /显示更早/ }); -afterEach(() => { - delete window.__TAURI__; -}); - -describe('Direct 聊天历史分页集成', () => { - it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => { - const invoke = install((args) => - args.beforeItemId - ? page(messages.slice(0, 6), false) - : page(messages.slice(6), true), - ); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); - fireEvent.click(screen.getByRole('button', { name: '发送' })); - await act(async () => { - await Promise.resolve(); - }); - expect(screen.queryByText('历史正文 0')).toBeNull(); - expect(screen.getByText('历史正文 25')).not.toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - for (let index = 0; index < messages.length; index += 1) { - expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1); - if (index > 0) { - expect( - screen - .getByText(`历史正文 ${index - 1}`) - .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - } - } - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_direct_project_history_slice', - ), - ).toHaveLength(4); - }); - - it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => { - const invoke = install((args) => - args.beforeItemId - ? page(messages.slice(0, 6), false) - : page(messages.slice(6), true), - ); - mount(); - await screen.findByText('历史正文 25'); - expect(screen.queryByText('历史正文 0')).toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); - expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { - projectPath, - beforeItemId: messages[6]!.id, - limit: 20, - messagesOnly: true, - }); - }); - - it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => { - const pending = deferred(); - let attempts = 0; - const invoke = install((args) => { - if (!args.beforeItemId) return page(messages.slice(6), true); - attempts += 1; - return attempts === 1 - ? pending.promise - : page(messages.slice(0, 8), false); - }); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - fireEvent.click(earlier()); - expect(attempts).toBe(1); - await act(async () => pending.reject(new Error('模拟读取失败'))); - expect(screen.getByText('历史正文 25')).not.toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - expect(screen.getAllByText('历史正文 6')).toHaveLength(1); - expect(screen.getAllByText('历史正文 7')).toHaveLength(1); - const loads = invoke.mock.calls.filter( - ([command, args]) => - command === 'read_direct_project_history_slice' && args?.beforeItemId, - ); - expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([ - messages[6]!.id, - messages[6]!.id, - ]); - }); - - it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => { - const old = deferred(); - const fresh = deferred(); - let fullLoads = 0; - let olderLoads = 0; - const recent = messages.slice(6).map((item) => ({ - ...item, - content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }], - })); - install((args) => { - if (!args.beforeItemId) { - fullLoads += 1; - return page(fullLoads === 1 ? messages.slice(6) : recent, true); - } - olderLoads += 1; - return olderLoads === 1 ? old.promise : fresh.promise; - }); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); - fireEvent.click(screen.getByRole('button', { name: '发送' })); - await screen.findByText('重读 历史正文 25'); - fireEvent.click(earlier()); - await act(async () => - old.resolve( - page( - [ - { - ...messages[0]!, - id: 'stale', - content: [{ type: 'input_text', text: '过期消息' }], - }, - ], - false, - ), - ), - ); - expect(screen.queryByText('过期消息')).toBeNull(); - fireEvent.click(earlier()); - expect(olderLoads).toBe(2); - await act(async () => fresh.resolve(page(messages.slice(0, 6), false))); - await screen.findByText('历史正文 0'); - }); - - it('离开项目并重进后,旧请求不能污染新实例', async () => { - const old = deferred(); - const invoke = install((args) => - args.beforeItemId ? old.promise : page(messages.slice(6), true), - ); - const first = mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - first.unmount(); - const other = mount('/tmp/another-project'); - await screen.findByText('历史正文 25'); - other.unmount(); - mount(); - await screen.findByText('历史正文 25'); - await act(async () => - old.resolve( - page( - [ - { - ...messages[0]!, - id: 'stale', - content: [{ type: 'input_text', text: '旧项目迟到消息' }], - }, - ], - false, - ), - ), - ); - expect(screen.queryByText('旧项目迟到消息')).toBeNull(); - expect(earlier()).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_direct_project_history_slice', - ), - ).toHaveLength(4); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..6d127375d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); + + it('首页读取失败时保留首屏锚点并返回错误', async () => { + const failure = new Error('首页读取失败'); + const readSlice = vi.fn(async (_beforeItemId: string | null) => { + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.firstItemId).toBeNull(); + expect(result.hasMore).toBe(false); + expect(result.error).toBe(failure); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..d9bb2b82f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; + +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; + +function event( + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; +} + +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { + return { + itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { + return { + itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + output: 'assets\ngame', + at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + ]); + expect(started.turnRunning).toBe(true); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(completed.turnRunning).toBe(false); + }); + + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); + // 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。 + expect(bootstrapped).not.toHaveProperty('subscriptionId'); + expect(bootstrapped).not.toHaveProperty('lastCompletedItemId'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); + }); + + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '你', + }), + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '好', + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + item: messageItem({ text: '你好,我是陶泥儿。' }), + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'reason-1', + kind: 'reasoning', + delta: '先看目录', + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); + }); + + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + item: messageItem({ + itemId: 'sys-1', + role: 'system', + text: '内部指令', + }), + }), + event({ + type: 'item.completed', + item: { + itemType: 'other', + itemId: 'plan-1', + rawType: 'plan', + at: 0, + } satisfies DirectThreadItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts deleted file mode 100644 index ff7849dfc..000000000 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - directThreadHistoryItemsToMessages, - directThreadHistoryPage, - isDirectTurnInProgress, - prependDirectHistoryMessages, -} from '../src/features/project-workspace/directThreadEvents'; - -describe('Direct 回合状态与历史时间', () => { - it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => { - const page = directThreadHistoryPage( - { - items: [{ id: 'tool-older', type: 'function_call_output' }], - hasMore: true, - oldestItemId: 'tool-older', - }, - 'message-newer', - ); - expect(page.messages).toEqual([]); - expect(page.cursor).toBe('tool-older'); - }); - it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => { - expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({ - messages: [], - cursor: null, - hasMore: false, - }); - expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow( - '游标未前进', - ); - expect(() => - directThreadHistoryPage( - { - items: [{ id: 'same' }], - hasMore: true, - oldestItemId: 'same', - }, - 'same', - ), - ).toThrow('游标未前进'); - }); - it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => { - const current = [ - { role: 'assistant' as const, text: '完整正文', messageId: 'a' }, - ]; - const old = { role: 'user' as const, text: '用户输入', messageId: 'u' }; - expect( - prependDirectHistoryMessages(current, [ - old, - old, - { role: 'assistant', text: '旧快照', messageId: 'a' }, - { role: 'assistant', text: '无身份旧消息' }, - ]), - ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]); - }); - - it('终态和空状态不恢复为活动回合', () => { - for (const status of [ - 'completed', - 'failed', - 'interrupted', - null, - undefined, - ]) { - expect(isDirectTurnInProgress(status)).toBe(false); - } - for (const status of ['accepted', 'running', 'streaming', 'finalizing']) { - expect(isDirectTurnInProgress(status)).toBe(true); - } - }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { - const items = [ - { - type: 'message', - role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], - }, - ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); - expect(items[0]).not.toHaveProperty('recordedAt'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f47f263a2..809041ff9 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -1,236 +1,241 @@ import { describe, expect, it } from 'vitest'; -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../src/app/types'; +import type { ChatMessage } from '../src/app/types'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + directChatEntriesToMessages, directMessageTimestamp, + mergeDirectChatMessages, normalizeDirectTimestamp, - splitDirectTurnContent, } from '../src/features/project-workspace/directTurnPresentation'; -const user = (turn: string): ChatMessage => ({ +const userEntry = ( + itemId: string, + at = 1_800_000_000_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'user', - text: '只读检查', - messageId: `direct-codex:${turn}:user`, + text: `问题 ${itemId}`, + at, }); -const assistant = (id: string, text = '完整回复'): ChatMessage => ({ + +const assistantEntry = ( + itemId: string, + text: string, + at = 1_800_000_001_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'assistant', text, - messageId: id, + at, }); -const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({ - schemaVersion: 'agc-turn-stream.v1', - kind: 'text', - turnId, - id: `text:${turnId}:${id}`, - text: '前缀', - seq, - at: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const tool = (turnId: string): GameCreatorDirectToolCall => ({ - schemaVersion: 'agc-tool-call.v1', - id: 'call', - turnId, - kind: 'mcp_tool', - title: '调用工具', - summary: '读取', - status: 'completed', - detail: { command: '{"path":"file"}', output: '内容', changes: [] }, - startedAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const build = ( - messages: ChatMessage[], - items: TurnStreamItem[], - options: Partial[0]> = {}, -) => - buildDirectTurnPresentations({ - messages, - visibleMessages: messages, - items, - calls: [], - transientReply: '', - ...options, - }); -describe('DirectProject 回合唯一呈现', () => { +const toolEntry = ( + itemId: string, + at = 1_800_000_000_500, +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: at, + updatedAt: at, + }, +}); + +const reasoningEntry = ( + itemId: string, + text = '先看目录', + at = 1_800_000_000_200, +): DirectChatEntry => ({ + itemId, + kind: 'reasoning', + role: null, + text, + at, +}); + +const localUser = (text: string, messageId?: string): ChatMessage => ({ + role: 'user', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_000, +}); + +const localNotice = (text: string, messageId?: string): ChatMessage => ({ + role: 'assistant', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_500, +}); + +describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); - expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000); - }); - - it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => { - const rows = build( - [user('one')], - [text('old', 'raw-old'), text('one', 'raw')], - { - hasUnloadedHistory: true, - }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - const active = build([], [text('live', 'raw')], { - hasUnloadedHistory: true, - activeTurnId: 'live', - }); - expect(active.map((row) => row.turnId)).toEqual(['live']); - }); - it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => { - const rows = build([], [text('one', 'raw')], { - activeTurnId: 'one', - transientReply: '同一份累计回复', - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].transientReply).toBe(''); - expect(rows[0].calls).toHaveLength(1); - }); - it('同一身份用户重复快照不增加回合,不丢用户正文', () => { - const rows = build([user('one'), user('one')], [text('one', 'raw')]); - expect(rows).toHaveLength(1); - expect(rows[0].messages).toHaveLength(1); - expect(rows[0].messages[0].role).toBe('user'); - }); - it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => { - const rows = build( - [user('one'), assistant('raw'), assistant('direct-codex:one:assistant')], - [text('one', 'raw')], - ); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].items).toHaveLength(1); - expect(rows[0].items[0].text).toBe('完整回复'); - }); - it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => { - const messages = [ - user('old'), - assistant('old-raw'), - user('one'), - assistant('raw'), - ]; - const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], { - visibleMessages: messages.slice(3), - }); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user'); - }); - it('纯文本旧回合不挤占之后有工具的回合身份', () => { - const rows = build( - [user('old'), assistant('plain'), user('one'), assistant('raw')], - [text('one', 'raw')], - { calls: [tool('one')] }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls).toEqual([]); - expect(rows[1].source).toBe('stream'); - }); - it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => { - const rows = build([user('one'), assistant('raw')], [], { - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls[0].detail.output).toBe('内容'); - }); - it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => { - const rows = build( - [user('one'), assistant('raw-a'), assistant('raw-b')], - [text('one', 'raw-b')], - ); - expect(rows[0].source).toBe('messages'); - expect( - rows[0].messages.filter((message) => message.role === 'assistant'), - ).toHaveLength(2); - }); - it('不同回合的相同文字不是重复消息,不做文本去重', () => { - const rows = build( - [user('one'), assistant('raw-one'), user('two'), assistant('raw-two')], - [text('one', 'raw-one'), text('two', 'raw-two')], - ); - expect(rows).toHaveLength(2); - expect(rows.every((row) => row.source === 'stream')).toBe(true); - }); - it('乱序重复快照按 seq 排列且不增加 item', () => { - const first = text('one', 'a'); - const last = text('one', 'b', 3); - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const rows = build([user('one')], [last, marker, first, first]); - expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]); - }); - it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => { - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const row = build( - [user('one')], - [text('one', 'start'), marker, text('one', 'final', 3)], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:start', - 'tool:one:call', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']); - const active = splitDirectTurnContent({ ...row, active: true }); - expect(active.processItems).toHaveLength(3); - expect(active.finalItems).toEqual([]); - }); - it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => { - const row = build( - [user('one'), assistant('direct-codex:one:failure', '失败')], - [text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:progress', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual([ - 'text:one:failure', - ]); - }); - it('无流历史只保留最后一条回复,发送时间不从工具推断', () => { - const row = build( - [user('one'), assistant('progress'), assistant('final')], - [], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processMessages.map((message) => message.messageId)).toEqual([ - 'progress', - ]); - expect(parts.finalMessages.map((message) => message.messageId)).toEqual([ - 'final', - ]); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); - it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => { + + it('每个用户条目开一个新回合,顺序就是条目顺序', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a1', '第一轮答复'), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); + expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); + expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); + expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[0]?.endedAt).toBe(1_800_000_001_000); + }); + + it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + reasoningEntry('r1'), + assistantEntry('a-mid', '我先看一下项目'), + toolEntry('t1'), + assistantEntry('a-final', '改好了', 1_800_000_002_000), + ], + }); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u1:r1', + 'u1:a-mid', + 'u1:t1', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); + }); + + it('连续工具合成一块,夹了正文就另起一块', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + toolEntry('t1'), + toolEntry('t2', 1_800_000_000_600), + assistantEntry('a-mid', '继续', 1_800_000_000_700), + toolEntry('t3', 1_800_000_000_800), + ], + turnRunning: true, + }); + const process = turns[0]?.process ?? []; + expect(process.map((block) => block.kind)).toEqual([ + 'tools', + 'assistant', + 'tools', + ]); + expect(process[0]).toMatchObject({ + kind: 'tools', + calls: [{ id: 't1' }, { id: 't2' }], + }); + expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); + }); + + it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a-live', '正在写'), + toolEntry('t1', 1_800_000_001_100), + ], + turnRunning: true, + }); + expect(turns[0]?.active).toBe(true); + expect(turns[0]?.finals).toEqual([]); + expect(turns[0]?.process.map((block) => block.kind)).toEqual([ + 'assistant', + 'tools', + ]); + }); + + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '正文')], + localMessages: [localNotice('后台任务失败:端口被占用')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ + 'assistant', + 'assistant', + ]); + expect(turns[0]?.finals[1]).toMatchObject({ + notice: true, + text: '后台任务失败:端口被占用', + }); + }); + + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '答复')], + localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], + turnRunning: true, + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); + expect(turns[1]?.users).toHaveLength(1); + expect(turns[1]?.active).toBe(true); + }); + + it('历史无用户条目时也保留一个回合承载正文', () => { + const turns = buildDirectChatTurns({ + entries: [assistantEntry('a1', '只有回复')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.users).toEqual([]); + expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); + }); + + it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => { + // 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。 + const turns = buildDirectChatTurns({ + entries: [ + reasoningEntry('r-prev'), + toolEntry('t-prev'), + assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '这一轮的答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u2']); + expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u2:r-prev', + 'u2:t-prev', + 'u2:a-prev', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']); + }); + + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { + const messages = directChatEntriesToMessages([ + userEntry('u1'), + reasoningEntry('r1'), + toolEntry('t1'), + assistantEntry('a1', '答复'), + ]); + expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( - build([user('one')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe('回复'); - expect( - build([user('one'), assistant('direct-codex:one:assistant')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe(''); + mergeDirectChatMessages(messages, [ + { role: 'assistant', text: '答复', messageId: 'a1' }, + localNotice('只在运行期的失败说明', 'local-1'), + ]).map((message) => message.messageId), + ).toEqual(['u1', 'a1', 'local-1']); }); }); diff --git a/docs/README.md b/docs/README.md index 3de2b212c..01f489d07 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 +- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md new file mode 100644 index 000000000..0642ca205 --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -0,0 +1,55 @@ +# 【ADR】DirectProject对话历史单一事实源-2026-09-16 + +状态:已接受 + +## 背景 + +AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 + +`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 + +## 决策 + +- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 +- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 +- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 +- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 +- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 +- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 +- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 +- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 +- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 +- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。锚点是切片**新端(较新一侧)的边界且含该条**(命令参数 `throughItemId`):比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠;向后翻页仍用切片返回的 `firstItemId` 作为 `beforeItemId`(不含锚点)。订阅回执到达之前不读首屏,也不退化成"取文件尾"(那会把回执之后才完成的条目也拉进历史)。 +- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。 +- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 +- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 +- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 +- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 + +## 备选方案与取舍 + +1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 +3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 + +## 影响 + +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 +- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 +- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md deleted file mode 100644 index db90eebeb..000000000 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,26 +0,0 @@ -# AGC 对话历史分页恢复实施计划 - -- Date: 2026-09-16 -- Status: awaiting-runtime-acceptance -- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md) - -## 实施 - -1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。 -2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 -3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 -4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 -5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 - -## 边界与停止条件 - -不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。 - -## 验收证据 - -- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。 -- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。 -- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。 -- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。 -- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。 -- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。 diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md index 9902abd0b..24f16aefa 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -32,7 +32,7 @@ | B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | | B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | | B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | -| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | +| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 | | B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | | C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | @@ -41,4 +41,4 @@ - 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。 - AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。 - 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。 -- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 +- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..933fca8a6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,43 @@ +# 【实施计划】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` | +| Status | implemented | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`agent/direct_thread_wire.rs`、`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、`src/features/project-workspace/generated/`(ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 +- 保持 `.env` 未提交修改,不触碰个人配置。 + +## 实现顺序 + +1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 +2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 +4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要为了拿这批事件再补一次 `consume`);此后只由 notify 唤醒 `consume`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 +6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;切片的新端边界由这个锚点给出(含该条,命令参数 `throughItemId`),首屏读取等订阅回执里的锚点,回执到达前不发请求、也不退化成「取文件尾」;之后锚点按原始 item id 推进(`beforeItemId`,不含锚点)。一次翻页操作在前端连拉,直到合并后聊天投影出现新回合(新的用户气泡)或 `hasMore=false`,每个操作上限 5 页;锚点未推进(`items` 为空 / `firstItemId` 为 null / 与请求锚点相同)时立即停止。可见性口径只在 `features/project-workspace/directHistoryPaging.ts` 实现一份,首屏与「显示更早」共用。 +7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。 +9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts` +4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:第 2 步的归一必须在 Rust 出口完成;前端不得再拿到两个 id。 +- 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 +- 订阅过期:以重新 `subscribe` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 回执竞态:`subscribe` 回执到达前产生的 `notify` 拿不到订阅身份,必须记欠账并在回执到达后补一次 `consume`;已有专门用例 `drains a notify that lands before the subscribe receipt` 钉住,改坏会让整轮事件卡死。 +- 合并规则退化为"先到定形"后,若某类条目只有输出没有调用条目,该输出不显示;这是有意取舍,先观察再决定是否补规则。 +- 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md deleted file mode 100644 index 4b8e244c4..000000000 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,20 +0,0 @@ -# AGC 对话历史分页恢复 - -- Version: 1 -- Status: reviewed -- Date: 2026-09-16 -- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) - -## 范围与评审 - -用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。 - -采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。 - -## 验收 - -1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。 -2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 -3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 -4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 -5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..0fae7ebb0 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,75 @@ +# 【里程碑】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented(自动化验收通过,运行时验收待补) | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | + +## 目标 + +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 + +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 + +## 范围 + +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 +- 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`。 +- 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;首屏切片的新端边界就是这个锚点(含该条,命令参数 `throughItemId`),比锚点更新的条目只从运行态事件来;向后翻页用切片返回的 `firstItemId`(`beforeItemId`,不含锚点)。后端切片只按原始条目切片,可见性判断留在前端聊天投影;一次翻页操作在前端连拉取页,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed` 把该回合运行态条目并入历史再清空。 +- 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 + +## 不在范围内 + +- 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 +- 跨进程回合账本、按回合统计与持久 turn ledger。 +- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 +- 旧项目磁盘上既有投影文件的清理、迁移或回填。 +- 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 + +## 已确认的决策 + +- 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 +- 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 +- 条目 id 只有一套:归一到 `itemId`;前端与 Thread Manager 都不再出现第二个 id。 +- 事件不带回合身份:`turn.started` 无载荷、`turn.completed{status}`;Thread Manager 的生命周期锚点、条目、增量与请求都不带 turn id。 +- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 +- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 +- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 +- 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 +- 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,`/history` 手动重读保持按当前文件尾取尾屏的恢复语义。 + +## 依赖与前置条件 + +- Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 +- 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同)。真实 app-server 会话核对仍作为运行时验收项。 + +## 验收标准 + +- [x] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。【自动化:`project-development.suite.ts` 空对话首轮 + 历史切片工具卡片用例】 +- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。【待真实 app-server 运行时验收】 +- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。【待真实 app-server 运行时验收】 +- [x] 历史切片从文件尾反向回扫,锚点始终是文件里的原始 item id;切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 +- [x] 首屏切片的新端边界是 `subscribe` 回执里的 `lastCompletedItemId`(含该条):比它更新的条目只从运行态事件来;锚点缺失时按文件尾取尾屏,订阅回执到达前不发首屏请求。【自动化:`cargo test agent::direct_project_history` 22 条(含 `through_item_id` 锚点用例)+ `directHistoryAnchorGate.test.ts` 10 条 + `project-development.suite.ts` 的 `anchors the first history page at the subscribe receipt instead of the file tail`(变异验证见下)】 +- [x] 前端一次翻页操作自动连拉,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页;锚点不前进时立即停止,不空转。【自动化:`directHistoryPaging.test.ts` 8 条 + `project-development.suite.ts` 跨页同回合用例(变异验证见下)】 +- [x] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream` 与 `read_direct_tool_calls` 命令已删除。`list_game_creator_direct_active_turns` **有意保留**:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不属于聊天框读路径。 +- [x] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。【自动化:reducer「先到定形、后到补空」合并单测 + 工具卡片渲染用例】 +- [x] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。【自动化:`directThreadChat` / `directTurnPresentation` 单测 + `cargo test export_bindings` 生成绑定无差异】 +- [x] 思考正文在回合进行中即可见,且不进入活动状态文本。【自动化:`directThreadChat` 的 `item.delta{kind:"reasoning"}` 单测】 +- [x] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。【自动化:reducer 过期重订阅单测】 +- [x] 通知先于 `subscribe` 回执到达时不丢事件:前端记欠账,回执到达后立刻补一次 `consume`。【自动化:`drains a notify that lands before the subscribe receipt` 用例】 + +## 证据要求 + +- 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`NODE_OPTIONS=--localstorage-file=… npx vitest run apps/ai-game-creator-shell/tests`(125 files / 1676 passed / 17 skipped)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint 与 `prettier`。 +- 自动化(分页连拉,已跑):`npx vitest run apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts`(8 passed)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(470 tests / 453 passed / 17 skipped,含新增的 `keeps pulling earlier pages while the page only deepens the rendered turn`)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)。变异验证:把停止判据退化成「这一页有可渲染条目就停」后,跨页用例以 `Unable to find an element with the text: 更早的用户提问` 变红,即用户报的「点了没变化」。 +- 自动化(首屏锚点,已跑):`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent::direct_project_history`(22 passed)、`npx vitest run apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts`(10 passed)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(475 tests / 457 passed / 17 skipped,含新增的 `anchors the first history page at the subscribe receipt instead of the file tail`)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint / prettier / `npm run check:encoding` / `npm run check:doc-index` / `git diff --check`。同一次全量里唯一失败是既有的 `edits the published runtime config without leaking API keys into chat`(`appSurface/runtime-settings.suite.ts`),与本次改动无关:把本次前端改动 stash 掉后它同样变红。变异验证:① 锚点闸门忽略「已消费」后,`directHistoryAnchorGate.test.ts` 的「同项目已消费返回 null」变红;② 首屏不等闸门后,`anchors the first history page at the subscribe receipt instead of the file tail` 以 `throughItemId` 缺失变红。 +- 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 +- 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 +- 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md index 191a6f606..c7e01f50a 100644 --- a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md @@ -1,29 +1,32 @@ # 对话回合唯一投影 -- Version: 2 -- Status: implemented-awaiting-runtime-acceptance +- Version: 3 +- Status: superseded - Date: 2026-09-16 +- Superseded by: `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` - Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md -## 范围与评审 +## 结论 -单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。 +本里程碑原先把「回合唯一投影」落在 Direct 回合事件 + `turn-stream.jsonl` + `tool-calls.jsonl` + 活动回合快照这条读路径上,方向已被推翻:聊天视图的输入只剩**项目对话历史**与**运行态事件**两项,Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照都不再是聊天视图的输入。后续实现与验收一律以 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 为准。 -## 验收 +## 仍然有效的部分 -1. 一个 turn 只有一个呈现入口,用户消息不丢失。 -2. item 增量、完成、持久化和回读保持相同身份与固定顺序。 -3. 工具输入输出保留,重复快照不重复渲染。 -4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。 -5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。 -6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。 +1. 一个回合只有一个呈现入口,用户消息不丢失;历史里带不了身份(没有 id)的旧记录不得做位置猜配。 +2. 同一 item 的增量、完成、持久化与回读使用同一个身份、同一个顺序;重复快照不重复渲染。 +3. 工具调用的输入与输出都保留,展开后仍显示「输入 / 输出」。 +4. 已结束回合重进不显示提交中;真实运行回合可恢复。 +5. 用户消息时间只来自条目自己的时间戳,旧无时间记录不造值;完成后中间正文与工具统一折叠,最终回复与失败提示保持可见。 +6. 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 -依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。 +## 已作废的部分 + +- 由活动回合快照接管运行中回合:改为由 `subscribe` bootstrap 事件判定(事件序列里 `turn.started` 之后没有 `turn.completed` 即运行中)。 +- 按 `turnId` 归并回合并把工具卡片挂在回合上:事件与条目都不再带回合身份,前端只有一个 `turnRunning` 布尔,卡片形状去掉 `turnId`。 +- 用 `turn-stream.jsonl` 的 `seq` 决定文本与工具的交替顺序:改为按运行态事件顺序 + 历史文件顺序投影。 +- 前端订阅 `game-creator-direct-turn-update`:改为 `subscribe` + `notify` 唤醒 `consume`(bootstrap 的事件直接 reduce,不再补一次 `consume`)。 ## 当前证据 -- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查、`git diff --check` 通过。 -- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。 -- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。 -- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。 -- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。 +- 里程碑版本的静态检查结论(TypeScript、Cargo、编码、文档索引、`git diff --check`)仍然成立;该增量按当时授权未运行测试。 +- 新路径的证据要求见 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 的「验收标准」与「证据要求」,其中包含解锁此前跳过的工具卡片渲染用例。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ccd014d82..ec473d2c0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,6 +18,16 @@ - 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。 ## 2026-09-17 图集切分模式改为显式声明 +## 2026-09-17 DirectProject 首屏历史锚点只认订阅回执的 lastCompletedItemId + +- 背景:ADR「首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片」只落了一半。`DirectThreadManager` 是搬运层,内存里没有「已完成条目」的锚点,`subscribe` 一律返回 `last_completed_item_id: None`;`commands.rs` 的 `subscribe_direct_project_thread` 在为空时用 `read_direct_project_last_item_id_at` 从磁盘回填,所以线上回执里的值是真的(订阅那一刻文件里最后一条可显示条目的原始 item id)。前端侧:首屏一直在 `loadProjectConversation` 里用 `beforeItemId: null` 直接取文件尾一屏,`lastCompletedItemId` 自 `1b40f030e` 起不再被任何代码读取。 +- 决策(锚点语义):首屏切片的新端(较新一侧)边界就是这个锚点,**含锚点条目本身**;切片命令新增 `throughItemId` 参数表达「取到这条为止」。比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠(原来的文件尾读取会把订阅回执之后才完成的条目也拉进历史,与运行态事件同 id 重叠,只靠前端合并兜住)。 +- 决策(读取时机):订阅回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏。`/history` 手动重读保持「按当前文件尾取尾屏」的恢复语义,不锚定。 +- 决策(翻页不变):向后翻页仍用切片返回的 `firstItemId` 作 `beforeItemId`(不含锚点),`hasMore` 与连拉口径不变。 +- 影响范围:`agent/direct_project_history.rs`(切片锚点 + `through_item_id` 参数)、`commands.rs`(`read_direct_project_history_slice` 命令参数)、AGC 前端首屏读取接线与测试骨架。**未改**:DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入与进度事件、`list_game_creator_direct_active_turns`、SpacetimeDB 与 HTTP 契约。 +- 验证方式(已跑):Rust 侧 `cargo test agent::direct_project_history`(22 passed,含「窗口取到锚点那条、排除比锚点更新的条目、`beforeItemId` 与 `throughItemId` 互斥报错」三类用例);前端 `npx vitest run .../directHistoryAnchorGate.test.ts`(10 passed)与 appSurface 的 `anchors the first history page at the subscribe receipt instead of the file tail`(全量 475 tests / 457 passed / 17 skipped;唯一失败 `edits the published runtime config without leaking API keys into chat` 与本次改动无关,stash 掉本次前端改动后同样变红);`tsc` / ESLint / prettier / `check:encoding` / `check:doc-index` / `git diff --check` 全绿。变异验证:闸门忽略「已消费」、首屏不等闸门两处改动各自让对应用例变红。 +- 关联文档:[ADR](../../adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md)、[里程碑](../plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md)、[实施计划](../plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md)。 + - 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 - 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。 - 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。 @@ -87,6 +97,16 @@ - 验证方式:`three_dimensional_game_request_frees_the_engine_choice`、`explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection`、`named_engine_requests_keep_the_existing_engineering_rule`、`three_dimensional_contract_reports_the_current_project_engine`、`home_three_dimensional_note_keeps_project_creation_available`、`system_prompt_is_bounded_and_declares_direct_runtime`,以及 `agent::direct_tools_mcp` 17 passed;`cargo fmt --check` 通过。本机临时目录 owner ACL 与进程用户不一致,涉及 `init_local_game_project_at` 的既有用例(含未改动模块)在本机无法执行,全量分片与真机 smoke 未在本轮取得。 - 关联文档:[里程碑](../plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md)、[实施计划](../plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md)。 +## 2026-09-17 DirectProject 历史分页的「可显示」口径定为前端回合反馈,一次翻页连拉上限 5 页 + +- 背景:ADR 与里程碑要求「一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页」,但实现只落地了后端锚点与文件尾回扫,前端仍是单发一页。历史切片的 `limit` 按原始条目计,一整页全是工具卡片 / 思考文本且落进同一个已渲染回合的折叠「执行过程」时,用户点「显示更早的对话」看不到任何变化。 +- 决策(可显示 = 出现新回合):一次翻页操作连续取页,停止判据是**合并后聊天投影的回合数增加**(出现新的用户气泡)。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但它们可能整页落进已渲染回合的折叠过程区,不构成用户可见反馈。 +- 决策(粒度和上限):每个用户操作最多 5 次请求,首屏那次算第 1 页、之后每次点击重新计数;每页仍是 `limit = CONVERSATION_VISIBLE_STEP`,上限只约束请求次数,不改页大小契约。 +- 决策(硬性终止):`items` 为空、`firstItemId` 为 null 或与请求锚点相同 → 立即停止,不靠 5 页上限兜底。 +- 决策(落点):口径与循环只在 `features/project-workspace/directHistoryPaging.ts` 一份实现里,首屏与「显示更早」共用;`DIRECT_HISTORY_MAX_PAGES_PER_ACTION` 放 `app/constants.ts`。 +- 边界:循环内只累积、结束后一次性并入聊天 state;某页失败时保留已成功页并沿用现有报错文案;切换项目时丢弃整批;不新增 loading / 禁用态与新文案,不做滚动锚定补偿;运行中回合允许连拉历史。 +- 验证:`directHistoryPaging` 单测 8 条覆盖口径、上限、`hasMore=false` 早停、锚点不前进、单页失败;`project-development.suite.ts` 新增「跨页同回合」集成用例作为真实回归网(变异验证:把判据退化成「有可渲染条目就停」后该用例变红);`tsc` 与 `appSurface.test.ts`(470 tests / 17 skipped)全绿。 + ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 @@ -8336,6 +8356,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 - Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 2026-09-17 追加:回显过滤必须同时覆盖**运行态事件侧**(`direct_thread_visible_item`,`item/started` 与 `rawResponseItem/completed` 两条路径),不能只过滤落盘。只过滤落盘时实时聊天会多出两条没有历史对应的孤儿用户气泡,各自开出一个「耗时 0 秒」的假回合,重进页面读同一份已过滤的 `project.jsonl` 又恢复正常;判据是 `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(回合事件里只能有一条 `direct-codex:{clientTurnId}:user` 用户条目)。 - 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 ## 2026-08-31 AGC 错误报告与诊断上传 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2a440a3b8..f6651d3b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -27,10 +27,6 @@ Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时 - **原因**:这个 shell 以管理员身份运行,`New-Item` / `tempfile` 新建目录的 owner 是 `BUILTIN\Administrators`,而 AGC 的校验要求 owner 等于当前用户 SID(`KDLETTERS\`)。`Get-Acl | Select Owner` 与 `whoami` 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。 - **处理**:手工夹具先 `icacls /setowner "\" /T`;Rust 用例改用工程自带的 `crate::tests::canonical_test_tempdir(prefix)`(它会 canonicalize 并重置目录 owner),不要直接用 `tempfile::tempdir()`。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。 -## DirectProject 历史不能按工具条目切页再按消息推进游标 - -原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。 - ## JSON 卡片显示与 UI 编辑能力必须同源 JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。 @@ -5695,10 +5691,17 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:在 `apps/ai-game-creator-shell/src-tauri` 跑过 `cargo test` 之后,`npm run ai-game-creator-shell:typecheck` 报 10 条类型错(`src/features/project-workspace/resourceReferences.ts:106-112` 的 `string | undefined` 不能赋给 `string | null`;同文件 134 行的对象字面量带 `type: 'message'`,而 `DirectCodexUserMessageItem` 里没有该字段),`npm run ai-game-creator-shell:build` 也死在 `beforeBuildCommand` 的同一条 typecheck 上。 - **原因**:crate 里的 ts-rs 导出会按**本机依赖版本**重写 `src/features/project-workspace/generated/DirectCodexUser*.ts`:注释头变成 "This file was generated…"、字符串改双引号、`DirectCodexUserMessageItem` 丢掉 `type: 'message'` 判别字段、并多出一个 `DirectCodexUserMessageEnvelope.ts`。仓库里提交的那份是前端真正依赖的形状(前端按带 `type` 的判别联合写),重写后两边就对不上——错在生成器版本漂移,不在前端。 -- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后先 `git checkout -- apps/ai-game-creator-shell/src/features/project-workspace/generated`,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`,然后才做 typecheck / 打包;绑定与前端形状冲突时以**已提交的绑定 + 前端**为基准排查。 -- **验证**:恢复提交版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的未跟踪文件,属同类生成产物。 +- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后只恢复 `apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUser*.ts` 的仓库版本,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`;不要恢复整个 `generated/` 目录,以免误删 DirectThread 的现役绑定。绑定与前端形状冲突时以**已提交的 DirectCodexUser 绑定 + 前端**为基准排查。 +- **验证**:恢复仓库版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的生成产物;它们不属于前端契约,发现后直接删除,不提交。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`(ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`beforeBuildCommand`)。 +## 2026-09-16 Node 26 下 vitest 的 jsdom 用例拿不到 window.localStorage + +- **现象**:只声明 `@vitest-environment jsdom` 的用例里 `window.localStorage` 是 `undefined`(典型报错 `Cannot read properties of undefined (reading 'clear')`);而 `appSurface.test.ts` 里同样访问 `window.localStorage` 却一切正常。 +- **原因(已核对,不是推断)**:Node 26 在 `globalThis` 上定义了实验性 `localStorage` 访问器,不传 `--localstorage-file` 时它返回 `undefined`。vitest 0.34 的 jsdom 环境把 jsdom window 的描述符复制到全局时,不会覆盖 `globalThis` 上已存在的键,于是 jsdom 真正的 `Storage` 被 Node 的 `undefined` 顶掉。`appSurface` 之所以不受影响,是因为 `apps/ai-game-creator-shell/tests/appSurface/harness.ts` 自己用 `Object.defineProperty(window, 'localStorage', …)` 挂了内存实现。 +- **处理**:跑这类用例时给 Node 加 `--localstorage-file`,例如 `NODE_OPTIONS="--localstorage-file=/tmp/vitest-node-localstorage" npx vitest run `;`apps/ai-game-creator-shell/tests/clientApi.test.ts` 与 `chatPromptPolish.test.tsx` 加这个开关后 26 项全绿。不要改业务代码或往测试里塞假 storage 来绕过。 +- **关联**:`vitest.config.ts`(`environment: 'node'` + 用例内 docblock 切 jsdom)、`apps/ai-game-creator-shell/tests/appSurface/harness.ts` 的内存 localStorage 垫片。 + ## 2026-09-17 AGC 壳首页无限 setState:effect 依赖了每次渲染都换身份的普通函数 - **现象**:dev 客户端停在首页、不点任何东西也会持续刷 `WEBVIEW error webview: Maximum update depth exceeded …`(5 秒涨 ~8.5 KB 日志),对应 WebView2 renderer 工作集涨到 **4.2 GB**、CPU 持续累计(约 0.7–1.5 核);表现上很像"模板库卡片太多/滚动卡",实际与页面内容无关。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3c864437b..40b9aa9b4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -48,10 +48,6 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 -- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 -- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 -- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 -- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index a2b266ecb..29e7165c6 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-15` +更新时间:`2026-09-16` ## 目标 @@ -18,7 +18,7 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 `project.jsonl` 的 `payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存运行态 delta/started 事件。 -Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影,再只发送 item 类型、item ID、delta 文本和 turn 终态等必要字段;不得把完整 item、工具参数或调用结果转发到前端。完整 item 仍只通过上述 JSONL 历史读取。 +Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影(挑字段、脱敏、截断、路径归一),再按与历史切片同形的脱敏原始条目(`itemType`、唯一 `itemId`、正文或工具明细)加 delta 文本与 turn 终态下发;搬运层不生成卡片形状,也不把未经脱敏的完整 item 转发到前端。注入 Codex 用的完整 item 仍只从上述 JSONL 历史读取。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 @@ -96,26 +96,54 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 -`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 +`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证——但前提是前端确实唤醒了 `consume`,见下条的回执竞态。 + +`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"先补一次 `consume` 才能拿到已暂存事件"的步骤。第一个例外只有回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,而前端要等回执到达才知道自己的 `subscriptionId`,这段窗口内的通知拿不到订阅身份。前端因此必须记一笔欠账,回执到达后立刻补一次 `consume` 取回那批事件;否则事件会卡在队列里等下一次通知,而一次回合的最后一个事件之后可能再也没有通知。除此之外不轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 + +首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 ### 事件和顺序 -Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: +Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thread Manager 的内部游标事实,不下发**:同一个 subscriber 的 `consume` 按队列顺序返回事件数组,数组顺序就是前端要处理的顺序,前端因此不需要 item 级 cursor 或第二套 reducer。 + +线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),前端消费 `src/features/project-workspace/generated/` 里的生成绑定,改 Rust 模型后跑 `cargo test export_bindings` 重新生成;毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 + +事件按 `type` 区分,条目按 `itemType` 区分: ```ts -{ - seq: number, - type: string, - turnId: string, - itemId?: string, - payload: unknown, -} +type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } + | { type: 'request'; kind: 'approval.requested' | 'ask.requested' | 'request.resolved'; requestId: string | null }; ``` -进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。 +进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 + +**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 +前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 + +生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 + +`item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + +条目形状的职责边界固定为三条: + +1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 +2. **只有一个条目身份**。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,同一调用的调用与输出共用前者;codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),所以在进队列前归一成一个 `itemId`。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 + +前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复;失败与中止说明只在运行期显示,不写进 `project.jsonl`。 + +历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 + +思考正文以 `item.delta{kind:"reasoning"}` 流式下发(来源是 app-server 的 `item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见文本范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本;plan 文本与命令输出仍然只降级为活动状态,不下发正文。 + ### 队列、subscriber 和回收 每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 11ce76401..886772b12 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -1,5 +1,14 @@ # 【技术方案】GameAgent 对话工具调用卡片(Codex 风格)-2026-09-14 +## 2026-09-16 修订(当前状态) + +本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: + +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`;`read_direct_tool_calls` 命令与 Rust 侧 `read_direct_tool_calls_at` 回读函数已删除,该文件现在只有 DirectRuntime 的写入。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。 +- 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit`:聊天卡片不再有回合身份。 +- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl` 的 `seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 + ## 一句话交付 把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。 @@ -47,15 +56,15 @@ toolCalls?: DirectTurnToolCall[] | null; - 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。 - `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。 -### 3. 回读命令 +### 3. 落盘契约(写侧) -新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`。 +DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。 - **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 - 历史文件缺失 → 返回空数组,不报错。 - 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 -### 4. 前端合并与渲染(回合唯一归属,连续工具成块) +### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。 From a2e6f205d660cfa8a2b2b6e0104bf991021d3d58 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 18 Sep 2026 04:55:05 +0800 Subject: [PATCH 56/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E5=A4=B1=E8=B4=A5=E6=B5=8B=E8=AF=95=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终回复失败测试显式关闭 Provider 重试 让非法回复 mock 按实际请求次数监听 同步运行态公共失败审计测试的 mock 参数 --- apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs | 10 ++++------ .../src-tauri/src/tests/runtime_state.rs | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index a91336e16..6f5317d98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1576,6 +1576,7 @@ pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec) -> pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( planning_response: String, + final_reply_requests: usize, ) -> String { let listener = bind_test_tcp_listener("mock invalid final reply bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); @@ -1617,11 +1618,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( .write_all(planning_response.as_bytes()) .expect("mock tool plan response"); - // Autonomous runs enforce a 12-retry floor. Return the same malformed - // response for the initial final-reply request and every retry so this - // fixture tests deserialize exhaustion rather than an accidental - // connection-refused fallback after the first malformed response. - for _ in 0..=12 { + for _ in 0..final_reply_requests.max(1) { let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); drop(read_mock_http_request(&mut final_stream)); let invalid_body = "{invalid-json"; @@ -5934,7 +5931,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "response": "" }) .to_string(); - let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json, 1); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -5943,6 +5940,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "baseUrl": {base_url:?}, "model": "design-runtime-model", "apiKind": "openai_responses", + "maxRetries": 0, "retryBackoffMs": 1 }} }} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 88db4e255..9515a6e4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -1147,7 +1147,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu "response": "" }) .to_string(); - let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response, 1); replace_test_local_config( &config_path, format!( From 6f012d419a6cf699cf8d0ea4c820ed8d5b8b2097 Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 18 Sep 2026 11:11:07 +0800 Subject: [PATCH 57/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMac=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=9A=8F=E5=8C=85=E8=BF=90=E8=A1=8C=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一Codex平台布局并补齐macOS原生组件、完整性清单和资源加载路径 补齐macOS插件资源并保留Windows专属原生桥接边界 增加隔离安装包验证、平台配置门禁与侧车回归测试 声明macOS 15最低系统版本并同步规范及Windows待验收计划 --- .gitignore | 6 + .../scripts/build-release.mjs | 7 + .../scripts/build-release.test.mjs | 16 ++ .../scripts/check-config.mjs | 39 ++- .../scripts/check-macos-bundle.mjs | 222 ++++++++++++++++++ apps/ai-game-creator-shell/src-tauri/build.rs | 130 ++++++---- .../src-tauri/build_support/codex_bundle.rs | 133 +++++++++++ .../【声明】Mac内置Codex组件-2026-09-18.md | 14 ++ .../src-tauri/src/agent/codex_cli.rs | 172 +++++++++----- .../src-tauri/tauri.macos.conf.json | 25 ++ ...计划】Mac客户端随包运行依赖补齐-2026-09-18.md | 25 ++ ...碑】Mac客户端随包运行依赖补齐-2026-09-18.md | 34 +++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 15 files changed, 729 insertions(+), 104 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md create mode 100644 apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json create mode 100644 docs/project-memory/plans/【实施计划】Mac客户端随包运行依赖补齐-2026-09-18.md create mode 100644 docs/project-memory/plans/【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md diff --git a/.gitignore b/.gitignore index 2770d44c8..34e0fbde7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/plugins/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /plugins/agc-cocos-editor/native/payload/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fc4bbc8f4..ffaa6854b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -342,6 +342,11 @@ export function buildTauriBuildArguments( .find((value) => value.startsWith('--target=')) ?.slice('--target='.length); const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; + if ((explicitTarget || target) === 'universal-apple-darwin') { + throw new Error( + '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', + ); + } const features = defaultEditorFeatures( explicitTarget || (noBundle ? platform : target), ); @@ -680,6 +685,8 @@ if ( path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); + // 目标校验必须先于远端版本读取与本地版本文件写入。 + buildTauriBuildArguments(args); if (!args.includes('--no-bundle')) await prepareReleaseVersion(); runTauriBuild(args); if (!args.includes('--no-bundle')) await generateUpdateManifest(); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index de0134267..1582e12c6 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + buildTauriBuildArguments, collectRecentReleaseCommits, collectReleaseCommits, compareVersions, @@ -34,6 +35,21 @@ import { const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; +test('native sidecar builds reject universal targets and accept each macOS architecture', () => { + assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); + assert.throws( + () => buildTauriBuildArguments(['--target=universal-apple-darwin']), + /单架构/, + ); + for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { + assert.deepEqual(buildTauriBuildArguments([], target), [ + 'build', + '--target', + target, + ]); + } +}); + function withEnv(overrides, run) { const previous = new Map(); for (const [key, value] of Object.entries(overrides)) { diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index ab7a0270c..fe8943608 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -35,6 +35,12 @@ const windowsTauriConfig = JSON.parse( 'utf8', ), ); +const macosTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.macos.conf.json', import.meta.url), + 'utf8', + ), +); const cargoManifestSource = fs.readFileSync( new URL('../src-tauri/Cargo.toml', import.meta.url), 'utf8', @@ -1358,6 +1364,37 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { 'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory', ); } +assert.deepEqual( + macosTauriConfig.bundle?.resources, + Object.fromEntries([ + ...[ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + 'NOTICE.md', + 'manifest.json', + ].map((file) => [ + `resources/codex/mac-native/${file}`, + `coding-agent/mac-native/${file}`, + ]), + ['resources/plugins', 'plugins'], + ]), + 'macOS must bundle the complete native Codex layout and plugin workspace', +); +assert.deepEqual( + macosTauriConfig.plugins?.updater?.endpoints, + [ + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', + ], + 'macOS local builds must not use the Windows update channel', +); +assert.equal( + macosTauriConfig.bundle?.macOS?.minimumSystemVersion, + '15.0', + 'macOS deployment baseline must cover the bundled native zsh requirement', +); if (tauriConfig.app?.withGlobalTauri !== true) { throw new Error( @@ -1722,7 +1759,7 @@ for (const snippet of [ 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', - 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH', + 'fn game_creator_bundled_codex_cli_path', 'validate_game_creator_bundled_codex_cli', '内置 Codex CLI 完整性校验失败', ]) { diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs new file mode 100644 index 000000000..78e38fdd9 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 +assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); +const source = path.resolve(process.argv[2] || ''); +assert.ok( + source.endsWith('.app') && fs.statSync(source).isDirectory(), + '请传入 .app 绝对路径', +); +const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), +); +const app = path.join(root, '陶泥儿 隔离测试.app'); +const home = path.join(root, 'home'); +const config = path.join(root, 'config'); +const tmp = path.join(root, 'tmp'); +const codexHome = path.join(root, 'codex-home'); +for (const directory of [home, config, tmp, codexHome]) { + fs.mkdirSync(directory, { mode: 0o700 }); +} +const env = { + HOME: home, + PATH: '/usr/bin:/bin', + TMPDIR: tmp, + CODEX_HOME: codexHome, +}; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + env, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + assert.ifError(result.error); + return result; +} + +async function hashFile(file) { + const hash = createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +async function handshake(executable) { + const child = spawn(executable, ['app-server'], { + cwd: root, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let buffered = ''; + let stderrBytes = 0; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('app-server 初始化超时')), + 15_000, + ); + const finish = (error) => { + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + child.on('error', finish); + child.on('exit', (code) => + finish(new Error(`app-server 提前退出 ${code}`)), + ); + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 1024 * 1024) + finish(new Error('app-server stderr 超限')); + }); + child.stdout.on('data', (chunk) => { + buffered += chunk.toString('utf8'); + if (buffered.length > 1024 * 1024) + return finish(new Error('app-server stdout 超限')); + let end; + while ((end = buffered.indexOf('\n')) >= 0) { + const line = buffered.slice(0, end); + buffered = buffered.slice(end + 1); + try { + const message = JSON.parse(line); + if (message.id !== 1) continue; + assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份'); + assert.equal(message.error, undefined); + child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`); + finish(); + } catch (error) { + finish(error); + } + } + }); + child.stdin.on('error', finish); + child.stdin.write( + `${JSON.stringify({ + id: 1, + method: 'initialize', + params: { + clientInfo: { + name: 'agc_bundle_smoke', + title: 'AGC bundle smoke', + version: '1', + }, + capabilities: { experimentalApi: true }, + }, + })}\n`, + ); + }); + } finally { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); + } + } +} + +try { + fs.cpSync(source, app, { recursive: true }); + const resources = path.join(app, 'Contents/Resources'); + const bundle = path.join(resources, 'coding-agent/mac-native'); + const executable = path.join(bundle, 'bin/codex'); + const main = path.join( + app, + 'Contents/MacOS/genarrative-ai-game-creator-shell', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), + ); + assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); + assert.equal( + manifest.platform, + process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', + ); + assert.equal(manifest.version, 'codex-cli 0.147.0'); + const components = [ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + ]; + assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort()); + for (const component of components) { + const file = path.join(bundle, component); + assert.equal(await hashFile(file), manifest.files[component], component); + if (component !== 'codex-package.json') { + fs.accessSync(file, fs.constants.X_OK); + const arch = run('/usr/bin/lipo', ['-archs', file]); + assert.equal(arch.status, 0, component); + assert.equal( + arch.stdout.trim(), + process.arch === 'arm64' ? 'arm64' : 'x86_64', + component, + ); + } + } + assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); + const plugin = path.join(resources, 'plugins/agc-cocos-editor'); + for (const file of [ + 'plugin.json', + 'src/entry.mjs', + 'panels/cocos-editor.html', + ]) { + assert.ok(fs.existsSync(path.join(plugin, file)), file); + } + const packageFiles = fs.readdirSync(resources, { recursive: true }); + assert.ok( + !packageFiles.some((file) => + /(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test( + file, + ), + ), + ); + assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); + assert.equal( + run(path.join(bundle, 'codex-path/rg'), ['--version']).status, + 0, + ); + assert.equal( + run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status, + 0, + ); + + // 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。 + const status = run(main, ['--config-dir', config, '--llm-status']); + const statusText = `${status.stdout}\n${status.stderr}`; + assert.ok(!statusText.includes('Codex CLI 未安装'), statusText); + assert.ok( + statusText.includes('authentication-required'), + '隔离账号应仅被登录门禁拒绝', + ); + await handshake(executable); + + // 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。 + fs.renameSync( + path.join(bundle, 'bin/codex-code-mode-host'), + path.join(root, 'saved-code-mode-host'), + ); + const broken = run(main, ['--config-dir', config, '--llm-status']); + assert.notEqual(broken.status, 0); + assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); + console.log( + 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', + ); + console.log( + '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', + ); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index e9f6642e0..6bca6d67a 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,31 +1,18 @@ +#[path = "build_support/codex_bundle.rs"] +mod codex_bundle; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -#[cfg(windows)] use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; -#[cfg(windows)] use std::io::{BufReader, Read}; -const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0"; - -#[cfg(windows)] -const BUNDLED_CODEX_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; - -#[cfg(windows)] fn sha256_file(path: &std::path::Path) -> Result { let file = fs::File::open(path)?; let mut reader = BufReader::new(file); @@ -42,7 +29,15 @@ fn sha256_file(path: &std::path::Path) -> Result { } fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - #[cfg(windows)] + let target = env::var("TARGET").expect("Cargo TARGET"); + println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); + let Some(layout) = codex_bundle::for_target(&target) else { + assert!( + !target.contains("windows") && !target.contains("apple-darwin"), + "不支持的 Codex 随包目标:{target}" + ); + return; + }; { let app_root = manifest_dir .parent() @@ -51,24 +46,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .parent() .and_then(|apps_dir| apps_dir.parent()) .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let source_candidates = [ - app_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - app_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - ]; + let package = layout.npm_package; + let source_candidates = [app_root, repo_root] + .into_iter() + .flat_map(|root| { + [ + root.join(format!("node_modules/@openai/{package}/vendor/{target}")), + root.join(format!( + "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" + )), + ] + }) + .collect::>(); let source = source_candidates .iter() .find(|path| { - BUNDLED_CODEX_FILES + layout + .files .iter() .all(|relative| path.join(relative).is_file()) }) @@ -83,14 +77,26 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .join(";") ) }); - let target_dir = manifest_dir.join("resources/codex/win-x64"); + let metadata: serde_json::Value = serde_json::from_slice( + &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + ) + .expect("Codex 原生包元数据无效"); + codex_bundle::validate_package_metadata(&metadata, &target, layout) + .unwrap_or_else(|error| panic!("{error}")); + let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let notice = target_dir.join("NOTICE.md"); + if target.contains("apple-darwin") { + let source_notice = + manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); } fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); let mut file_hashes = serde_json::Map::new(); - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { let source_path = source.join(relative); let target_path = target_dir.join(relative); if let Some(parent) = target_path.parent() { @@ -104,15 +110,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { if !target_matches_source { fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); } + // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 + fs::set_permissions( + &target_path, + fs::metadata(&source_path) + .expect("读取组件权限失败") + .permissions(), + ) + .expect("保留内置 Codex CLI 组件权限失败"); file_hashes.insert( relative.to_string(), serde_json::Value::String(source_sha256), ); } let manifest = serde_json::json!({ - "schemaVersion": "genarrative-codex-sidecar.v2", - "platform": "win32-x64", - "version": BUNDLED_CODEX_CLI_VERSION, + "schemaVersion": codex_bundle::SCHEMA, + "platform": layout.platform, + "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); let manifest_path = target_dir.join("manifest.json"); @@ -126,7 +140,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { { fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); } - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { println!("cargo:rerun-if-changed={}", source.join(relative).display()); } println!("cargo:rerun-if-changed={}", notice.display()); @@ -256,8 +270,11 @@ fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} /// /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 /// Cargo target 目录或 node_modules。 -#[cfg(windows)] fn stage_plugin_workspace(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + if !target.contains("windows") && !target.contains("apple-darwin") { + return; + } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) @@ -266,6 +283,10 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .to_path_buf(); let workspace = repo_root.join("plugins"); let destination_root = manifest_dir.join("resources/plugins"); + // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 + if destination_root.exists() { + std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); + } std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); let entries = match std::fs::read_dir(&workspace) { Ok(entries) => entries, @@ -273,6 +294,13 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { }; for entry in entries.flatten() { let plugin_root = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件目录类型失败") + .is_symlink(), + "插件工作区不允许符号链接" + ); if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { continue; } @@ -287,17 +315,18 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { std::path::PathBuf::from("panels"), std::path::PathBuf::from("native/payload"), ] { + if relative == std::path::Path::new("native/payload") && !target.contains("windows") { + continue; + } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); } println!("cargo:rerun-if-changed={}", plugin_root.display()); } } -#[cfg(windows)] fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let Ok(bytes) = std::fs::read(source) else { - return; - }; + let bytes = std::fs::read(source) + .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { return; } @@ -307,7 +336,6 @@ fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { std::fs::write(destination, bytes).expect("复制插件资源失败"); } -#[cfg(windows)] fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { let entries = match std::fs::read_dir(source) { Ok(entries) => entries, @@ -316,10 +344,17 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { for entry in entries.flatten() { let target = destination.join(entry.file_name()); let path = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件文件类型失败") + .is_symlink(), + "插件资源不允许符号链接" + ); if path.is_dir() { let name = entry.file_name(); let name = name.to_string_lossy(); - if matches!(name.as_ref(), "target" | "node_modules" | ".git") { + if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { continue; } std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); @@ -331,12 +366,14 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { if name.contains(".test.") { continue; } + if name.starts_with('.') { + continue; + } stage_plugin_file(&path, &target); } } } -#[cfg(windows)] fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { if !source.is_file() { return; @@ -345,6 +382,3 @@ fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { .expect("创建插件资源目录失败"); std::fs::copy(source, destination).expect("复制插件资源失败"); } - -#[cfg(not(windows))] -fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs new file mode 100644 index 000000000..1811f6a25 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -0,0 +1,133 @@ +//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 + +pub const VERSION: &str = "0.147.0"; +pub const CLI_VERSION: &str = "codex-cli 0.147.0"; +pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +#[derive(Clone, Copy, Debug)] +pub struct Layout { + pub platform: &'static str, + pub npm_package: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +const WINDOWS_FILES: &[&str] = &[ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json", +]; +const MAC_FILES: &[&str] = &[ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json", +]; + +pub fn for_target(target: &str) -> Option { + match target { + "x86_64-pc-windows-msvc" => Some(Layout { + platform: "win32-x64", + npm_package: "codex-win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: WINDOWS_FILES, + }), + "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { + platform: if target.starts_with("aarch64") { + "darwin-arm64" + } else { + "darwin-x64" + }, + npm_package: if target.starts_with("aarch64") { + "codex-darwin-arm64" + } else { + "codex-darwin-x64" + }, + directory: "mac-native", + executable: "bin/codex", + files: MAC_FILES, + }), + _ => None, + } +} + +pub fn validate_package_metadata( + metadata: &serde_json::Value, + target: &str, + layout: Layout, +) -> Result<(), String> { + if metadata["layoutVersion"] == 1 + && metadata["version"] == VERSION + && metadata["target"] == target + && metadata["entrypoint"] == layout.executable + && metadata["resourcesDir"] == "codex-resources" + && metadata["pathDir"] == "codex-path" + { + Ok(()) + } else { + Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_layouts_are_explicit_and_preserve_upstream_components() { + let mac = for_target("aarch64-apple-darwin").unwrap(); + assert_eq!(mac.platform, "darwin-arm64"); + assert_eq!(mac.npm_package, "codex-darwin-arm64"); + assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh")); + assert!(mac.files.contains(&"bin/codex-code-mode-host")); + assert!(!mac.files.iter().any(|file| file.ends_with(".exe"))); + let intel = for_target("x86_64-apple-darwin").unwrap(); + assert_eq!(intel.platform, "darwin-x64"); + assert_eq!(intel.npm_package, "codex-darwin-x64"); + let windows = for_target("x86_64-pc-windows-msvc").unwrap(); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.files.len(), 6); + assert!(windows + .files + .contains(&"codex-resources/codex-windows-sandbox-setup.exe")); + assert!(for_target("universal-apple-darwin").is_none()); + assert!(for_target("aarch64-pc-windows-msvc").is_none()); + assert!(for_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn metadata_rejects_version_architecture_and_layout_drift() { + let target = "aarch64-apple-darwin"; + let layout = for_target(target).unwrap(); + let valid = serde_json::json!({ + "layoutVersion": 1, + "version": VERSION, + "target": target, + "entrypoint": "bin/codex", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + }); + assert!(validate_package_metadata(&valid, target, layout).is_ok()); + for (key, value) in [ + ("layoutVersion", serde_json::json!(2)), + ("version", serde_json::json!("0.0.0")), + ("target", serde_json::json!("x86_64-apple-darwin")), + ("entrypoint", serde_json::json!("bin/codex.exe")), + ("resourcesDir", serde_json::json!("../private")), + ("pathDir", serde_json::json!(null)), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!( + validate_package_metadata(&invalid, target, layout).is_err(), + "{key}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md new file mode 100644 index 000000000..affea34fb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md @@ -0,0 +1,14 @@ +# 内置 Codex CLI + +本安装包包含锁定版本 Codex CLI 0.147.0 的 macOS 原生组件。 + +Codex CLI 按 Apache License 2.0 分发,源码与许可证见 +https://github.com/openai/codex。 + +组件来自项目锁定的 `@openai/codex` 原生 npm 依赖,保留上游的 +`bin/codex`、`bin/codex-code-mode-host`、`codex-path/rg`、 +`codex-resources/zsh/bin/zsh` 和 `codex-package.json` 相对布局。 +原生依赖中的 ripgrep 与 zsh 按各自上游许可证分发: +https://github.com/BurntSushi/ripgrep 和 https://www.zsh.org/。 + +安装包不包含 API Key、登录状态、用户配置或项目数据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 863b8cdad..79e3aedd1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -5,18 +5,10 @@ use std::process::Stdio; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +#[path = "../../build_support/codex_bundle.rs"] +mod codex_bundle; + const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = - "coding-agent/win-x64/manifest.json"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; @@ -38,11 +30,11 @@ fn game_creator_codex_cli_executable_candidates_for( path: Option<&std::ffi::OsStr>, ) -> Vec { let mut candidates = Vec::new(); + if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) { + candidates.push(bundled); + } #[cfg(windows)] { - if let Some(resource_dir) = resource_dir { - candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)); - } fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { let vendor_root = npm_root .join("node_modules") @@ -109,52 +101,71 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_bundled_resource_dir() -> Option { + let executable = std::env::current_exe().ok()?; + game_creator_bundled_resource_dir_for(&executable) +} + +fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option { #[cfg(windows)] { - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)) + executable.parent().map(Path::to_path_buf) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] { + let macos = executable.parent()?; + let contents = macos.parent()?; + // 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。 + if macos.file_name()? != "MacOS" + || contents.file_name()? != "Contents" + || contents.parent()?.extension()? != "app" + { + return None; + } + Some(contents.join("Resources")) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = executable; None } } fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option { - resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)) + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?; + Some( + resource_dir? + .join("coding-agent") + .join(layout.directory) + .join(layout.executable), + ) } fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result { + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")) + .ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?; let bundle_root = executable .parent() .and_then(Path::parent) .ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?; - let manifest_path = bundle_root.join( - Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH) - .file_name() - .expect("bundled Codex manifest file name"), - ); + let manifest_path = bundle_root.join("manifest.json"); let manifest = std::fs::read_to_string(&manifest_path) .map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string()) .and_then(|value| { serde_json::from_str::(&value) .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) })?; - if manifest.schema_version != "genarrative-codex-sidecar.v2" - || manifest.platform != "win32-x64" + if manifest.schema_version != codex_bundle::SCHEMA + || manifest.platform != layout.platform || manifest.version.trim().is_empty() - || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES - .iter() - .any(|relative| { - manifest.files.get(*relative).map_or(true, |hash| { - hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) + || layout.files.iter().any(|relative| { + manifest.files.get(*relative).map_or(true, |hash| { + hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) }) + }) { return Err("内置 Codex CLI 完整性清单不受支持".to_string()); } - for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { + for relative in layout.files { let path = bundle_root.join(relative); let bytes = std::fs::read(&path).map_err(|_| { format!( @@ -163,7 +174,7 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。 diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 778dddf1f..462d9929c 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -69,6 +69,8 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M 除 AppData 导入外,宿主还扫描 `plugins/` 工作区:每个含根目录 `plugin.json` 的一级子目录是一个插件包。解析顺序为环境变量 `AGC_PLUGIN_WORKSPACE`、随包资源目录 `/plugins`、开发构建的仓库 `plugins/`。仓库工作区约定见 [`plugins/README.md`](../../../plugins/README.md)。 +Windows 与 macOS 构建都将内置插件的清单、JS 入口与面板复制到应用资源目录;staging 每次重建,避免已删除插件或跨目标原生 payload 残留。macOS 不携带 Windows native payload。Cocos 进程桥接仍仅按既有 Windows 平台实现提供,插件文件可被发现不代表 macOS 已支持编辑器控制;JS 入口的系统 Node 前提不变。 + ### 内置插件与可用开关 `plugins/` 工作区里的插件是**内置插件**:随客户端分发,用户不能卸载或删除,只能通过可用开关控制是否生效。开关状态持久化在 AppData `extensions/builtin-plugins.json`(`schemaVersion = agc.builtin-plugins.v1`,`enabled` 是 id 到布尔的映射);文件缺失按插件 manifest 的 `enabled` 处理,坏文件失败关闭。内置插件优先级高于同名导入插件,AppData 里的同名 Plugin 不会覆盖或间接卸载它。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 40b9aa9b4..3cb988ec5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -352,6 +352,10 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。 - 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.147.0` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。 - Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- macOS 单架构安装包同样必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。单架构资源不能冒充 universal 包。 +- 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 +- macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 +- macOS 安装包的系统下限取主程序和全部原生组件中的最高要求;锁定 Codex 0.147.0 原生依赖所携带的 zsh 要求 macOS 15.0,因此 `bundle.macOS.minimumSystemVersion` 明确为 `15.0`。更新原生依赖时重新检查 Mach-O 的系统下限,不能只按 AGC 主程序宣称兼容版本。 - CLI 安全边界:CLI 固定使用 argv 启动,禁止 shell 拼接;工作目录使用本次请求专用的空临时目录,不把游戏项目绝对路径写入 prompt、stdout、stderr 或持久记录。调用固定使用 ephemeral、忽略用户配置和 exec rules、read-only sandbox、never approval,并关闭 Codex shell tool;只继承 CLI 运行和认证所需的最小环境,显式移除宿主 `CODEX_API_KEY`。用户级 Codex 登录态继续由本机 Codex 自己读取,API Key、auth 文件、Cookie、Token、`CODEX_HOME` 私有内容不得复制到项目配置、Runtime sidecar、Agent DB、conversation 或日志;stdout / stderr 无换行时也受硬上限约束,stderr 诊断只记录固定分类、字节数和 SHA-256。 - 协议边界:Runtime 把既有 `LlmRunRequest` 的消息和当前函数目录编码为有界 prompt,并从同一函数 JSON Schema 生成 Codex structured-output schema。CLI 输出转换为现有 `LlmRunResponse / LlmToolCall` 后,继续经过 native tool / MCP 参数校验、动作上限、权限、pending、receipt、验证与格式修复链;最终回复仍走唯一提交路径,不新增平行响应协议。 - 取消与恢复:Codex 子进程绑定当前 Provider request lifecycle,取消、暂停、Runner draining 或 GUI owner 丢失时终止并回收当前进程;started 后没有可信终态仍沿现有 Provider reconciliation 处理。`agentMode`、CLI 可执行身份和影响输出的 Codex 参数进入 `providerConfigFingerprint`,模式切换不得消费另一模式遗留的 retry/handoff。 From 16c905b51bdb996ce5d1b22862d2c535a45ba384 Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 18 Sep 2026 12:15:00 +0800 Subject: [PATCH 58/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E4=B8=8E=E6=8F=92=E4=BB=B6=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=B9=B6=E5=90=8C=E6=AD=A5=E7=89=88=E6=9C=AC?= =?UTF-8?q?0.1.67?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一显式目标对应的版本读取、构建渠道、产物目录和更新清单 无原生适配器时隐藏Cocos插件并阻止自动启动 同步单架构更新规范并增加发布和插件回归测试 更新版本与锁文件到0.1.67并记录Mac安装包验证结果 --- apps/ai-game-creator-shell/package.json | 2 +- .../scripts/build-release.mjs | 194 ++++++++++----- .../scripts/build-release.test.mjs | 224 +++++++++++++++++- .../scripts/release-upload.mjs | 7 +- .../src-tauri/Cargo.lock | 2 +- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/builtin_plugins.rs | 4 +- .../src-tauri/src/editor_adapters.rs | 10 +- .../src-tauri/src/plugin_host.rs | 75 +++++- .../src-tauri/tauri.conf.json | 2 +- apps/ai-game-creator-shell/src/App.tsx | 7 +- .../src/services/pluginHost.ts | 10 + .../tests/pluginHost.test.ts | 63 +++++ ...计划】Mac客户端随包运行依赖补齐-2026-09-18.md | 6 +- ...碑】Mac客户端随包运行依赖补齐-2026-09-18.md | 10 +- docs/project-memory/shared-memory/pitfalls.md | 4 + ...方案】AGC客户端更新检查与下载-2026-08-31.md | 11 +- ...案】AGC通用插件宿主与编辑器适配-2026-09-09.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + package-lock.json | 2 +- 20 files changed, 548 insertions(+), 90 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/pluginHost.test.ts diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 3d56329d7..dffaab261 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.47", + "version": "0.1.67", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index ffaa6854b..b1fc307f5 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -14,16 +14,71 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); // 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; -const releaseTarget = - process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; -const bundleRoot = path.join( - appRoot, - 'src-tauri', - 'target', - releaseTarget, - 'release', - 'bundle', -); +function defaultTarget() { + return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; +} + +function explicitBuildTarget(args) { + let target; + const separator = args.indexOf('--'); + const options = separator < 0 ? args : args.slice(0, separator); + for (let index = 0; index < options.length; index += 1) { + const argument = options[index]; + let value; + if (argument === '--target' || argument === '-t') { + value = options[++index]; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else { + continue; + } + if (!value?.trim() || value.startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = value.trim(); + } + return target; +} + +function validateReleaseTarget(target) { + if (target === 'universal-apple-darwin') { + throw new Error( + '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', + ); + } + if ( + ![ + 'x86_64-pc-windows-msvc', + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ].includes(target) + ) { + throw new Error(`不支持的发布目标:${target}`); + } + return target; +} + +/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */ +export function resolveReleaseContext(args = [], env = process.env) { + const target = validateReleaseTarget( + explicitBuildTarget(args) || + env.AGC_BUILD_TARGET?.trim() || + defaultReleaseTarget, + ); + return Object.freeze({ + target, + channel: resolveReleaseChannel(env, target), + bundleRoot: path.join( + appRoot, + 'src-tauri', + 'target', + target, + 'release', + 'bundle', + ), + }); +} const packageJsonPath = path.join(appRoot, 'package.json'); const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); @@ -99,7 +154,7 @@ export function nextPatchVersion(localVersion, remoteVersion) { return `${major}.${minor}.${patch + 1}`; } -export function resolveReleasePlatform(target = releaseTarget) { +export function resolveReleasePlatform(target = defaultTarget()) { if (target.includes('windows')) return 'windows'; if (target.includes('apple-darwin')) return 'darwin'; if (target.includes('linux')) return 'linux'; @@ -108,7 +163,7 @@ export function resolveReleasePlatform(target = releaseTarget) { export function resolveReleaseChannel( env = process.env, - target = releaseTarget, + target = defaultTarget(), ) { const platform = resolveReleasePlatform(target); const requested = env.AGC_UPDATE_CHANNEL?.trim(); @@ -142,13 +197,10 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) { } /** - * 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂 - * `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。 + * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 */ -export function resolveManifestPlatformKeys(target = releaseTarget) { - if (target === 'universal-apple-darwin') { - return ['darwin-aarch64', 'darwin-x86_64']; - } +export function resolveManifestPlatformKeys(target = defaultTarget()) { + validateReleaseTarget(target); if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target.includes('windows')) { @@ -256,8 +308,8 @@ function replaceVersionLine(source, version, pattern, label) { return source.replace(pattern, `$1${version}$3`); } -export async function prepareReleaseVersion() { - const channel = resolveReleaseChannel(); +export async function prepareReleaseVersion(context = resolveReleaseContext()) { + const { channel } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); @@ -330,23 +382,14 @@ export async function prepareReleaseVersion() { export function buildTauriBuildArguments( args = [], - target = releaseTarget, + target = defaultTarget(), platform = process.platform, ) { const noBundle = args.includes('--no-bundle'); - const targetIndex = args.indexOf('--target'); - const explicitTarget = - targetIndex >= 0 - ? args[targetIndex + 1] - : args - .find((value) => value.startsWith('--target=')) - ?.slice('--target='.length); + const explicitTarget = explicitBuildTarget(args); const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; - if ((explicitTarget || target) === 'universal-apple-darwin') { - throw new Error( - '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', - ); - } + if (!noBundle || explicitTarget) + validateReleaseTarget(explicitTarget || target); const features = defaultEditorFeatures( explicitTarget || (noBundle ? platform : target), ); @@ -379,18 +422,33 @@ function writeChannelConfigFile(channel) { return configPath; } -export function runTauriBuild(args = []) { - const tauriArguments = buildTauriBuildArguments(args); - if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) { - const channel = resolveReleaseChannel(); - const configPath = writeChannelConfigFile(channel); - console.log( - `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, - ); - tauriArguments.push('--config', configPath); +export function runTauriBuild( + args = [], + context = resolveReleaseContext(args), + { spawn = spawnSync } = {}, +) { + if ( + explicitBuildTarget(args) && + explicitBuildTarget(args) !== context.target + ) { + throw new Error('构建参数与发布上下文目标不一致'); } + const tauriArguments = buildTauriBuildArguments(args, context.target); + const { channel } = context; + const configPath = writeChannelConfigFile(channel); + console.log( + `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, + ); + // 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。 + const separator = tauriArguments.indexOf('--'); + tauriArguments.splice( + separator < 0 ? tauriArguments.length : separator, + 0, + '--config', + configPath, + ); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const result = spawnSync( + const result = spawn( npmCommand, ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments], { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, @@ -407,11 +465,11 @@ function listFiles(root) { }); } -function artifactPriority(filePath) { +function artifactPriority(filePath, target) { const name = path.basename(filePath).toLowerCase(); - if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99; + if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99; // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。 - if (releaseTarget.includes('apple-darwin')) { + if (target.includes('apple-darwin')) { return name.endsWith('.app.tar.gz') ? 0 : 99; } if (name.endsWith('.appimage.tar.gz')) return 0; @@ -421,7 +479,8 @@ function artifactPriority(filePath) { return 99; } -export function selectReleaseArtifact(files) { +export function selectReleaseArtifact(files, target = defaultTarget()) { + validateReleaseTarget(target); const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); if (explicit) { const resolved = path.resolve(explicit); @@ -432,9 +491,10 @@ export function selectReleaseArtifact(files) { } return ( [...files] - .filter((filePath) => artifactPriority(filePath) < 99) + .filter((filePath) => artifactPriority(filePath, target) < 99) .sort((left, right) => { - const priority = artifactPriority(left) - artifactPriority(right); + const priority = + artifactPriority(left, target) - artifactPriority(right, target); return priority || left.localeCompare(right); })[0] ?? null ); @@ -455,13 +515,15 @@ function readUpdaterSignature(artifactPath) { export function createUpdateManifest( artifactPath, { - channel = resolveReleaseChannel(), - target = releaseTarget, + target = defaultTarget(), + channel = resolveReleaseChannel(process.env, target), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), } = {}, ) { + validateReleaseTarget(target); + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); @@ -609,9 +671,11 @@ export function createLegacyUpdateManifest( }; } -export async function generateUpdateManifest() { - const channel = resolveReleaseChannel(); - const artifact = selectReleaseArtifact(listFiles(bundleRoot)); +export async function generateUpdateManifest( + context = resolveReleaseContext(), +) { + const { channel, target, bundleRoot } = context; + const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } @@ -628,7 +692,7 @@ export async function generateUpdateManifest() { `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } - const manifest = createUpdateManifest(artifact, { channel, notes }); + const manifest = createUpdateManifest(artifact, { channel, target, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const notesPath = path.join(bundleRoot, 'release-notes.txt'); @@ -680,14 +744,24 @@ export async function generateUpdateManifest() { }; } +export async function buildRelease( + args = [], + { + prepareVersion = prepareReleaseVersion, + build = runTauriBuild, + generateManifest = generateUpdateManifest, + } = {}, +) { + const context = resolveReleaseContext(args); + if (!args.includes('--no-bundle')) await prepareVersion(context); + build(args, context); + if (!args.includes('--no-bundle')) return generateManifest(context); +} + if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); - // 目标校验必须先于远端版本读取与本地版本文件写入。 - buildTauriBuildArguments(args); - if (!args.includes('--no-bundle')) await prepareReleaseVersion(); - runTauriBuild(args); - if (!args.includes('--no-bundle')) await generateUpdateManifest(); + await buildRelease(args); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 1582e12c6..8cb9c0214 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + buildRelease, buildTauriBuildArguments, collectRecentReleaseCommits, collectReleaseCommits, @@ -23,11 +24,14 @@ import { createUpdateManifest, formatRecentReleaseNotes, formatReleaseNotes, + generateUpdateManifest, nextPatchVersion, resolveManifestPlatformKeys, resolvePreviousReleaseCommit, resolveReleaseChannel, + resolveReleaseContext, resolveRemoteHighWaterVersion, + runTauriBuild, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; @@ -148,9 +152,12 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); -test('universal macOS builds publish one artifact under both platform keys', () => { - assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ +test('macOS manifests only advertise the architecture actually built', () => { + assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); + assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ 'darwin-aarch64', + ]); + assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [ 'darwin-x86_64', ]); assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ @@ -158,6 +165,218 @@ test('universal macOS builds publish one artifact under both platform keys', () ]); }); +test('release context resolves explicit targets before environment/default and fails closed', () => { + for (const args of [ + ['--target', 'aarch64-apple-darwin'], + ['--target=aarch64-apple-darwin'], + ['-t', 'aarch64-apple-darwin'], + ]) { + for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { + const context = resolveReleaseContext(args, env); + assert.equal(context.target, 'aarch64-apple-darwin'); + assert.equal(context.channel, 'dev-mac'); + assert.match( + context.bundleRoot.replaceAll('\\', '/'), + /target\/aarch64-apple-darwin\/release\/bundle$/, + ); + assert.ok(Object.isFrozen(context)); + } + assert.throws( + () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), + /只能用于 windows/, + ); + } + assert.equal(resolveReleaseContext([], {}).target, windowsTarget); + assert.equal( + resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) + .channel, + 'dev-mac', + ); + for (const args of [ + ['--target'], + ['--target='], + ['--target', '--no-bundle'], + ['--target', windowsTarget, '--target=aarch64-apple-darwin'], + ['--target', universalTarget], + ['--target', 'unknown'], + ]) + assert.throws(() => resolveReleaseContext(args, {})); +}); + +test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => { + const calls = []; + const seenContexts = []; + await withStubbedFetch( + (url) => { + calls.push(url); + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({ version: '0.1.67' }); + }, + () => + withEnv( + { AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined }, + () => + buildRelease(['--target', 'aarch64-apple-darwin'], { + prepareVersion: async (context) => { + seenContexts.push(context); + assert.equal( + await resolveRemoteHighWaterVersion(context.channel), + '0.1.67', + ); + }, + build: (args, context) => { + seenContexts.push(context); + runTauriBuild(args, context, { + spawn: (_binary, command) => { + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-mac\/latest\.json$/, + ); + assert.ok(command.includes('aarch64-apple-darwin')); + assert.ok( + !command.includes('--features=cocos-editor-execute'), + ); + return { status: 0 }; + }, + }); + }, + generateManifest: (context) => { + seenContexts.push(context); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + assert.equal( + selectReleaseArtifact( + ['/tmp/win.exe', artifact, '/tmp/mac.dmg'], + context.target, + ), + artifact, + ); + const manifest = createUpdateManifest(artifact, context); + assert.deepEqual(Object.keys(manifest.platforms), [ + 'darwin-aarch64', + ]); + assert.match( + manifest.platforms['darwin-aarch64'].url, + /\/dev-mac\//, + ); + }); + }, + }), + ), + ); + assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针'); + assert.equal(seenContexts.length, 3); + assert.ok(seenContexts.every((context) => context === seenContexts[0])); +}); + +test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'mac package'); + writeFileSync(`${artifact}.sig`, 'mac signature'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + const context = { + ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), + bundleRoot: root, + }; + const result = await withStubbedFetch( + (url) => { + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({}, 404); + }, + () => generateUpdateManifest(context), + ); + assert.equal(result.artifact, artifact); + assert.equal(result.manifestPath, path.join(root, 'latest.json')); + assert.equal(result.legacyManifestPath, null); + assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']); + assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('invalid target or mismatched channel fails before any release side effect', async () => { + let touched = false; + const sideEffects = { + prepareVersion: () => { + touched = true; + }, + build: () => { + touched = true; + }, + generateManifest: () => { + touched = true; + }, + }; + await assert.rejects( + () => buildRelease(['--target', universalTarget], sideEffects), + /单架构/, + ); + await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => + assert.rejects( + () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), + /只能用于 windows/, + ), + ); + assert.equal(touched, false); +}); + +test('Windows remains the default and explicit Windows overrides macOS environment', () => { + const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; + for (const context of [ + resolveReleaseContext([], {}), + resolveReleaseContext(['--target', windowsTarget], { + AGC_BUILD_TARGET: 'aarch64-apple-darwin', + }), + ]) { + assert.equal(context.channel, 'dev-win'); + assert.equal( + selectReleaseArtifact(files, context.target), + '/tmp/windows.exe', + ); + runTauriBuild( + ['--target', windowsTarget, '--config', 'user-config.json'], + context, + { + spawn: (_binary, command) => { + assert.ok(command.includes('--features=cocos-editor-execute')); + assert.ok(command.includes('user-config.json')); + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-win\/latest\.json$/, + ); + return { status: 0 }; + }, + }, + ); + } +}); + +test('no-bundle smoke skips version writes and manifest generation', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { + prepareVersion: () => { + steps.push('version'); + }, + build: (_args, context) => { + steps.push(context.channel); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['dev-mac']); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { @@ -361,6 +580,7 @@ test('release upload forces overwrite for artifact, signature and channel pointe ); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); + assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); }); test('release notes list client commits with short sha and bound their size', () => { diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 3d1cff921..d39b2fa9f 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -12,8 +12,7 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; const dryRun = readReleaseDryRun(); -const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = - await import('./build-release.mjs'); +const { buildRelease } = await import('./build-release.mjs'); function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; @@ -51,10 +50,8 @@ function runOssutil(args) { if (result.status !== 0) process.exit(result.status ?? 1); } -await prepareReleaseVersion(); -runTauriBuild([]); const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - await generateUpdateManifest(); + await buildRelease(process.argv.slice(2)); const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 42699068e..bc1227832 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.47" +version = "0.1.67" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index f5cc8b673..5c3e8eb64 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.47" +version = "0.1.67" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index d236d77c8..0896b32eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -237,7 +237,7 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> { /// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { plugin.exposes_agent_tools() - && cfg!(feature = "cocos-editor-execute") + && cfg!(all(windows, feature = "cocos-editor-execute")) && is_enabled(plugin.id()) } @@ -431,7 +431,7 @@ mod tests { let _guard = test_lock(); let directory = tempdir().expect("temp config"); initialize(directory.path()).expect("initialize"); - let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute"); + let tool_visible_when_enabled = cfg!(all(windows, feature = "cocos-editor-execute")); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index ba2949e8b..4c5cf1444 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -4,10 +4,10 @@ //! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` //! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use std::path::PathBuf; -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use tauri::Manager; use crate::plugin_host::PluginHost; @@ -21,20 +21,20 @@ pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, ) -> Result<(), String> { - #[cfg(feature = "cocos-editor")] + #[cfg(all(windows, feature = "cocos-editor-execute"))] { let adapter = cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); host.register_editor_adapter(Box::new(adapter))?; } - #[cfg(not(feature = "cocos-editor"))] + #[cfg(not(all(windows, feature = "cocos-editor-execute")))] { let _ = (app, host); } Ok(()) } -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { let mut candidates = Vec::new(); if let Ok(resource_dir) = app.path().resource_dir() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 51838ea81..389d0486d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -767,6 +767,22 @@ fn permission_for_method(method: &str) -> Option<&'static str> { } } +fn has_cocos_editor_adapter(editors: &EditorRegistry) -> Result { + Ok(editors + .lock() + .map_err(|_| "编辑器注册表锁已损坏".to_string())? + .contains_key("cocos-editor")) +} + +fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { + if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID + && !has_cocos_editor_adapter(editors)? + { + return Err("当前客户端不支持 Cocos 编辑器桥接".to_string()); + } + Ok(()) +} + impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; @@ -948,11 +964,12 @@ impl PluginHost { .flatten() .is_some() }); + let cocos_available = cocos_project && has_cocos_editor_adapter(&state.editors)?; state .plugins .values() .filter(|record| { - record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_project + record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_available }) .map(|record| self.summary_locked(record)) .collect() @@ -1017,6 +1034,7 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); + require_plugin_adapter(id, &state.editors)?; if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project .lock() @@ -1124,6 +1142,7 @@ impl PluginHost { .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; + require_plugin_adapter(id, &state.editors)?; let record = state .plugins .get(id) @@ -1535,6 +1554,7 @@ impl PluginHost { Ok(json!({"path": input.path, "content": content})) } "host.rpc" => { + require_plugin_adapter(&manifest.id, editors)?; let input: EditorRpcInput = descriptor_from_params(params)?; if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project @@ -2122,6 +2142,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) @@ -2223,6 +2245,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); let project = tempdir().expect("web project"); @@ -2235,4 +2259,53 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host.start("agc-cocos-editor").is_err()); } + + #[test] + fn cocos_plugin_requires_registered_adapter_even_for_a_cocos_project() { + let _guard = crate::builtin_plugins::test_lock(); + let directory = tempdir().expect("temp config"); + fs::write( + directory.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .unwrap(); + fs::create_dir(directory.path().join("assets")).unwrap(); + crate::builtin_plugins::initialize(directory.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(directory.path()).unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .list_extensions() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .start("agc-cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host + .read_panel("agc-cocos-editor", "cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .is_none()); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 2c8833ab9..f52b347b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿", - "version": "0.1.47", + "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bfd4cb80d..f49e8b792 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -301,7 +301,10 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from './services/platformSession'; -import { setAgcPluginProjectPath, startAgcPlugin } from './services/pluginHost'; +import { + setAgcPluginProjectPath, + startAvailableAgcPlugin, +} from './services/pluginHost'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -612,7 +615,7 @@ export function App({ void setAgcPluginProjectPath(nextProjectPath) .then(async () => { if (workspaceProjectKind === 'cocos' && nextProjectPath) { - await startAgcPlugin('agc-cocos-editor'); + await startAvailableAgcPlugin('agc-cocos-editor'); } }) .catch((error) => { diff --git a/apps/ai-game-creator-shell/src/services/pluginHost.ts b/apps/ai-game-creator-shell/src/services/pluginHost.ts index 3ca0f9bf7..5d4fcb45e 100644 --- a/apps/ai-game-creator-shell/src/services/pluginHost.ts +++ b/apps/ai-game-creator-shell/src/services/pluginHost.ts @@ -33,6 +33,16 @@ export async function startAgcPlugin(id: string) { }) as Promise; } +/** 只消费宿主的能力投影,不因项目类型自行推断原生适配器是否存在。 */ +export async function startAvailableAgcPlugin(id: string) { + const plugins = await listAgcPlugins(); + const plugin = plugins.find((candidate) => candidate.id === id); + if (!plugin?.enabled || !plugin.hasRuntime || plugin.status === 'invalid') { + return; + } + return startAgcPlugin(id); +} + export async function stopAgcPlugin(id: string) { return invokeOrThrow()('stop_agc_plugin', { id, diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts new file mode 100644 index 000000000..86995c21c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { startAvailableAgcPlugin } from '../src/services/pluginHost'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('插件自动启动使用后端能力投影', () => { + it.each( + [ + [], + [ + { + id: 'agc-cocos-editor', + enabled: false, + hasRuntime: true, + status: 'stopped', + }, + ], + [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: false, + status: 'package', + }, + ], + [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: true, + status: 'invalid', + }, + ], + ].map((plugins) => ({ plugins })), + )('隐藏、禁用或不可执行的插件不启动(%j)', async ({ plugins }) => { + const invoke = vi.fn(async () => plugins); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await startAvailableAgcPlugin('agc-cocos-editor'); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('list_agc_plugins'); + }); + + it('支持的 Cocos 插件继续按原入口启动', async () => { + const invoke = vi.fn(async (command: string) => + command === 'list_agc_plugins' + ? [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: true, + status: 'stopped', + }, + ] + : {}, + ); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await startAvailableAgcPlugin('agc-cocos-editor'); + expect(invoke).toHaveBeenLastCalledWith('start_agc_plugin', { + id: 'agc-cocos-editor', + }); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】Mac客户端随包运行依赖补齐-2026-09-18.md b/docs/project-memory/plans/【实施计划】Mac客户端随包运行依赖补齐-2026-09-18.md index ebf7c293f..c1b00a62f 100644 --- a/docs/project-memory/plans/【实施计划】Mac客户端随包运行依赖补齐-2026-09-18.md +++ b/docs/project-memory/plans/【实施计划】Mac客户端随包运行依赖补齐-2026-09-18.md @@ -1,12 +1,14 @@ # Mac 客户端随包运行依赖补齐实施计划 -- Version: 1 +- Version: 2 - Status: awaiting-windows-acceptance - Date: 2026-09-18 - Parent Spec: `【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md` ## 修改顺序 +本轮评审修复顺序:统一 release context(覆盖 build/upload 两入口)→ 版本/端点/产物/清单的定向回归 → 宿主列表/启动/面板与前端自动启动能力门禁 → 同步单架构权威文档 → Node/Vitest/Rust/typecheck/编码/文档/diff 检查。用户随后授权同步 master、重打 0.1.67 并推送当前 PR 分支;不读取私钥、不上传安装包、不合并 PR。 + 1. 提取构建与运行共用的 Codex 平台布局;按 Cargo TARGET stage 锁定原生依赖并校验包元数据,保留可执行位。 2. 增加 macOS 专属 Tauri 资源映射、声明、产物忽略规则;复用插件 staging,不复制 Windows 原生 payload。 3. 修正 `.app/Contents/Resources` 定位与平台清单验证,保持外部安装回退。 @@ -18,7 +20,7 @@ - `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent::codex_cli::tests:: -- --test-threads=1` - `npm run ai-game-creator-shell:typecheck` - 定向 Node 打包契约测试、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` -- 本地 Tauri 构建关闭 updater artifact;不修改源码版本或读取发布私钥,不运行 release upload。 +- 本地 Tauri 构建关闭 updater artifact;版本按用户要求统一为 0.1.67,不读取发布私钥,不运行 release upload。 ## 风险与停止条件 diff --git a/docs/project-memory/plans/【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md b/docs/project-memory/plans/【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md index c7b3339e4..7fae803e0 100644 --- a/docs/project-memory/plans/【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】Mac客户端随包运行依赖补齐-2026-09-18.md @@ -1,6 +1,6 @@ # Mac 客户端随包运行依赖补齐 -- Version: 1 +- Version: 2 - Status: awaiting-windows-acceptance - Date: 2026-09-18 - Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` / Runtime 边界 / 安装包侧车 @@ -27,6 +27,14 @@ ## 验收现状与剩余门禁 +### 评审反馈修订合同 + +2026-09-18 编码前自审:本轮仅修复同里程碑的目标平台传递、Cocos 能力门禁和文档冲突,不新增原生桥接或发布管线。CLI 显式目标必须覆盖环境默认,并在版本、构建、产物和清单全链路保持一致;不支持平台或渠道错配应在副作用前失败。Cocos 必须由已注册适配器决定可见/启动,覆盖无适配器、有适配器及非 Cocos 项目;前端不盲目启动隐藏插件。更新权威文档改为单架构策略,拒绝 universal,且不宣称现有发布脚本支持跨构建合并两种架构。新增回归通过后仍等待 Windows 验收;本地 0.1.67 版本修改保留,不与旧 0.1.47 安装包证据混淆。 + +评审修复验证:发布/feature/上传脚本测试 32 项通过,前端插件自动启动 5 项通过,Rust PluginHost 11 项与内置插件 10 项通过;Rust 使用 `TMPDIR=/private/tmp` 避免 macOS `/var` 系统链接触发既有路径安全断言。类型/配置、定向 ESLint、编码、文档索引和 diff 检查通过。测试覆盖显式目标覆盖环境、错误渠道提前拒绝、Tauri 实际注入配置、真实临时清单写入、Windows 默认 feature 保留、无 adapter 隐藏/启动拒绝、有 adapter RPC 回归。上述是自动化证据,不代表 Windows 真机、GUI 或带本次修复的新安装包已验收。 + +重打验证:重新 fetch/merge `origin/master` 确认当前分支已包含最新 master;按用户要求将 package、Tauri、Cargo 与锁文件版本同步到 `0.1.67`。包含上述修复的 Release `.app` 构建通过,Info.plist 实测版本 `0.1.67`、最低系统 `15.0`;隔离安装包脚本再次通过,DMG 用 hdiutil 生成并校验通过。此版本仍等待用户 GUI 与 Windows 回归,不做正式签名、公证、更新签名或产物上传。 + - Mac 本地测试包已由用户确认“可以用了”;不外推为全部对话、工具和其它机器兼容性已覆盖。 - 定向 Rust 验证 14 项通过,1 项真实认证用例按原配置跳过;发布脚本测试 21 项通过;隔离 HOME/PATH 的安装包资源检查、正式 Codex 查找、app-server 握手及缺组件拒绝通过。 - 类型与配置、编码、文档索引、定向脚本 lint 和 diff 检查通过;139 MiB DMG 完整性通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cd5365cc7..f77e2447e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## 发布目标与原生能力必须贯穿完整入口 + +显式 `--target` 不能只改变 Tauri 命令参数;AGC 发布入口必须把同一解析结果传给版本高水位、更新端点、产物目录/后缀和清单平台键,否则 macOS 构建可能错误使用 Windows 渠道。插件文件存在也不代表 native 能力可用:Cocos 在宿主注册表缺少适配器时应隐藏并拒绝启动,前端自动启动消费后端列表投影,不能仅凭项目类型推断能力。发布策略以客户端更新权威文档为准,单架构资源不能登记成双架构产物。 + ## macOS 安装包小不代表运行依赖齐全 AGC 的 DMG 生成成功只证明应用可以被打包。平台专属 Codex staging、Tauri resource 映射、运行时资源目录定位和辅助组件 SHA-256 清单必须同时闭合;只配置 Windows 资源会让 Mac 开发机因全局 Codex 而掩盖缺包。macOS 使用锁定原生依赖中的 Codex、code-mode host、rg 和 zsh,不能复制 Windows EXE/DLL。用 `scripts/check-macos-bundle.mjs`(AGC 应用目录下)对复制到临时目录的 `.app` 做限制 PATH、隔离 HOME 的正式查找、app-server 握手和缺组件拒绝检查;GUI、账号、Provider 与 Cocos 原生桥接另行验收。插件 JS 入口仍依赖系统 Node,不得将“插件文件随包”表述为“无需任何外部工具链”。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 53233a8de..03bd90534 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -75,11 +75,11 @@ | 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 | | --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ | | `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` | -| `dev-mac` | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | +| `dev-mac` | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | - 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。 -- macOS 使用 universal 包:`dev-mac` 按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),清单把同一个 `.app.tar.gz` 与同一个签名分别写入 `darwin-aarch64` 与 `darwin-x86_64`,升级后仍是 universal 包。这是 Tauri 官方发布工具对 universal 产物的既有写法。 -- 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。 +- macOS 当前采用单架构包:Apple Silicon 使用 `aarch64-apple-darwin`,Intel 使用 `x86_64-apple-darwin`;每次生成的清单只登记本次实际构建的架构,不把单架构原生 Codex 资源挂到另一架构。`universal-apple-darwin` 在版本读取/写入、构建和清单生成之前拒绝。 +- 渠道清单以实际运行架构为键。两种单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新;当前不实现跨构建合并,Intel 发布需先完成其构建验证与多架构清单发布方案。 - 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 - 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 - 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。 @@ -90,6 +90,7 @@ ## 构建与发布 - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 +- 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 @@ -105,7 +106,7 @@ | 条款 | 验收方式 | 证据 | | ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | -| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| macOS 单架构清单与 universal 拒绝 | 定向发布脚本测试 | 单架构各用对应平台键;拒绝未闭合的 universal 发布 | | 缺签名时失败关闭 | 同上 | 通过 | | 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | | 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | @@ -127,7 +128,7 @@ 已决策: -- macOS 采用 universal 包,同一产物同时挂 `darwin-aarch64` 与 `darwin-x86_64` 两个清单键(见「契约与迁移」)。 +- macOS 采用单架构包,只登记实际构建架构;Intel 真机构建与跨架构清单合并未验收,不公开宣称双架构分发就绪。 - 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 - 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 - macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 diff --git a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md index 462d9929c..4eed982a6 100644 --- a/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md +++ b/docs/technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md @@ -71,6 +71,8 @@ OpenAI 的标准模型是“Plugin 作为可安装包,组合 Skills、可选 M Windows 与 macOS 构建都将内置插件的清单、JS 入口与面板复制到应用资源目录;staging 每次重建,避免已删除插件或跨目标原生 payload 残留。macOS 不携带 Windows native payload。Cocos 进程桥接仍仅按既有 Windows 平台实现提供,插件文件可被发现不代表 macOS 已支持编辑器控制;JS 入口的系统 Node 前提不变。 +Cocos 插件对用户可见与可启动必须同时满足当前为 Cocos 项目、宿主已注册 `cocos-editor` 原生适配器;没有适配器时从插件/扩展列表隐藏,直接启动或读取面板也在产生子进程前拒绝。正式适配器仅在 Windows 且编译 `cocos-editor-execute` 时注册;Agent 工具使用相同平台与 feature 门禁。前端只按后端列表投影判断是否自动启动,不自行推断平台能力。 + ### 内置插件与可用开关 `plugins/` 工作区里的插件是**内置插件**:随客户端分发,用户不能卸载或删除,只能通过可用开关控制是否生效。开关状态持久化在 AppData `extensions/builtin-plugins.json`(`schemaVersion = agc.builtin-plugins.v1`,`enabled` 是 id 到布尔的映射);文件缺失按插件 manifest 的 `enabled` 处理,坏文件失败关闭。内置插件优先级高于同名导入插件,AppData 里的同名 Plugin 不会覆盖或间接卸载它。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3cb988ec5..d9e6f4601 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -356,6 +356,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 - macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 - macOS 安装包的系统下限取主程序和全部原生组件中的最高要求;锁定 Codex 0.147.0 原生依赖所携带的 zsh 要求 macOS 15.0,因此 `bundle.macOS.minimumSystemVersion` 明确为 `15.0`。更新原生依赖时重新检查 Mach-O 的系统下限,不能只按 AGC 主程序宣称兼容版本。 +- 发布链路的目标解析和单架构清单以《AGC客户端更新检查与下载》为准:CLI 目标优先,版本、构建、端点、bundle 与更新清单共用单一发布上下文。插件能力以《AGC通用插件宿主与编辑器适配》为准:无已注册 Cocos 原生适配器时隐藏且拒绝启动,前端自动启动只消费后端可用性投影。 - CLI 安全边界:CLI 固定使用 argv 启动,禁止 shell 拼接;工作目录使用本次请求专用的空临时目录,不把游戏项目绝对路径写入 prompt、stdout、stderr 或持久记录。调用固定使用 ephemeral、忽略用户配置和 exec rules、read-only sandbox、never approval,并关闭 Codex shell tool;只继承 CLI 运行和认证所需的最小环境,显式移除宿主 `CODEX_API_KEY`。用户级 Codex 登录态继续由本机 Codex 自己读取,API Key、auth 文件、Cookie、Token、`CODEX_HOME` 私有内容不得复制到项目配置、Runtime sidecar、Agent DB、conversation 或日志;stdout / stderr 无换行时也受硬上限约束,stderr 诊断只记录固定分类、字节数和 SHA-256。 - 协议边界:Runtime 把既有 `LlmRunRequest` 的消息和当前函数目录编码为有界 prompt,并从同一函数 JSON Schema 生成 Codex structured-output schema。CLI 输出转换为现有 `LlmRunResponse / LlmToolCall` 后,继续经过 native tool / MCP 参数校验、动作上限、权限、pending、receipt、验证与格式修复链;最终回复仍走唯一提交路径,不新增平行响应协议。 - 取消与恢复:Codex 子进程绑定当前 Provider request lifecycle,取消、暂停、Runner draining 或 GUI owner 丢失时终止并回收当前进程;started 后没有可信终态仍沿现有 Provider reconciliation 处理。`agentMode`、CLI 可执行身份和影响输出的 Codex 参数进入 `providerConfigFingerprint`,模式切换不得消费另一模式遗留的 retry/handoff。 diff --git a/package-lock.json b/package-lock.json index ab6ee8ab6..c454dc6af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,7 @@ }, "apps/ai-game-creator-shell": { "name": "@genarrative/ai-game-creator-shell", - "version": "0.1.47", + "version": "0.1.67", "dependencies": { "@cubone/react-file-manager": "^1.35.0", "@genarrative/image-canvas-core": "0.1.0", From 2ffccb844c790c0e54d644b5a1e0b0bc6236f154 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 18 Sep 2026 12:17:28 +0800 Subject: [PATCH 59/68] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=AE=A1=E6=97=B6=E4=B8=8E=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=87=8D=E5=A4=8D=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=B1=85=E4=B8=AD=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分整轮总耗时与工具实际阶段时间,以百毫秒刷新一位小数并冻结终态。 修复正式与乐观用户消息到达时初始占位重复,保留真实同文多次发送。 在开发 Agent 系统提示与 Skill 明确禁止 Phaser 和 CSS 双重居中。 补齐原生事件、前端回归与验收文档,保留未完成项和远程 CI 风险。 --- .../agc-web-game-development/SKILL.md | 3 + .../references/game-quality-checklist.md | 2 + .../resources/agc-skills/manifest.json | 4 +- .../src/agent/codex_app_server/mod.rs | 128 +++++- .../src-tauri/src/agent/direct_runtime/mod.rs | 9 +- .../src/agent/direct_thread_manager.rs | 74 +++- .../src-tauri/src/agent/direct_thread_wire.rs | 373 +++++++++++++++++- apps/ai-game-creator-shell/src/App.tsx | 10 +- .../ProjectSupervisorView.tsx | 95 +++-- .../project-workspace/ToolCallGroup.tsx | 117 +++--- .../project-workspace/directThreadChat.ts | 177 ++++++++- .../directThreadItemProjection.ts | 201 +++++++--- .../directTurnPresentation.ts | 51 ++- .../generated/DirectThreadEvent.ts | 44 ++- .../toolCallGroupPresentation.ts | 211 ++++++---- .../features/project-workspace/useLiveNow.ts | 29 ++ .../tests/appSurface/harness.ts | 8 +- .../appSurface/project-development.suite.ts | 213 +++++++++- .../tests/appSurface/tool-call-group.suite.ts | 233 ++++++++--- .../tests/directThreadChat.test.ts | 201 ++++++++++ .../tests/directTurnPresentation.test.ts | 81 +++- ...计划】对话总耗时与动态工具计时-2026-09-18.md | 33 ++ ...程碑】对话总耗时与动态工具计时-2026-09-18.md | 35 ++ docs/project-memory/shared-memory/pitfalls.md | 6 + .../shared-memory/team-conventions.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 27 +- 27 files changed, 2010 insertions(+), 361 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts create mode 100644 docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md create mode 100644 docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index c9de909a6..b6e070a85 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage 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. + - **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT` 与 `autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。 + - 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。 + - 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。 6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and 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. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md index 77bdf1f9c..5eeea8e90 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -7,5 +7,7 @@ - Score, steps, health, timer, or other core state updates consistently. - Restart restores all state and does not duplicate timers, animation loops, or event listeners. - Desktop and mobile layouts keep the core scene visible without accidental document scrolling. +- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。 +- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。 - HUD and overlays reserve space and do not cover essential interactive content. - Requested Taonier art is visibly integrated into the core experience when available. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6d4201d96..d1a51e9af 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.24", + "version": "2026-08-26.25", "skills": [ { "name": "agc-game-production-workflow", @@ -80,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" + "sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7bf8b2484..f35701eac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3046,13 +3046,24 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let direct_thread_id = direct_thread_id_for_project(history_root); + // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 + // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 + // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 + // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_started(direct_turn_started_at_ms), + ); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + // 用户消息由 AGC 自己落盘,条目时间就是真实发送时间:事件级 `at` 直接 + // 复用这一条条目的时间,不另取宿主钟。 + let user_item_at = entry_item.at(); append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed(entry_item, user_item_at), ); } } @@ -3191,9 +3202,14 @@ impl CodexAppServerConnection { .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); if let Some(entry_item) = entry_item { + // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 + // 通知的钟就是该阶段唯一可证明的时间。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed( + entry_item, + direct_tool_call_now_ms(), + ), ); } } @@ -3322,9 +3338,19 @@ impl CodexAppServerConnection { if let Some(entry_item) = direct_thread_visible_item(history_root, item) { + // `item/started` 的通知层带 `startedAtMs`:这是工具真正 + // 开始的阶段时间,优先于条目展示时间与宿主钟。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_started(entry_item), + DirectThreadEvent::item_started( + entry_item, + direct_thread_item_event_at_ms( + ¶ms, + item, + false, + direct_tool_call_now_ms(), + ), + ), ); } } @@ -3367,9 +3393,18 @@ impl CodexAppServerConnection { && matches!(status, "completed" | "interrupted" | "failed") { terminal_recorded = true; + // 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生, + // 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::turn_completed(status.to_string()), + DirectThreadEvent::turn_completed( + status.to_string(), + direct_thread_turn_completed_at_ms( + turn, + Some(direct_turn_started_at_ms), + direct_tool_call_now_ms(), + ), + ), ); } match status { @@ -3426,6 +3461,9 @@ impl CodexAppServerConnection { "failed" } .to_string(), + // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟, + // 不能拿最后一次正文或工具更新时间当回合终点。 + direct_tool_call_now_ms(), ), ); } @@ -3680,7 +3718,7 @@ pub(crate) fn cancel_direct_codex_turn_at( // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 append_direct_thread_event( &direct_thread_id_for_project(root), - DirectThreadEvent::turn_completed("aborted".to_string()), + DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()), ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), @@ -5006,6 +5044,80 @@ mod tests { )); } + /// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema: + /// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒), + /// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。 + /// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住; + /// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。 + #[test] + fn direct_lifecycle_stage_times_come_from_notification_params() { + let started = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + let completed = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": 1_700_000_001_500u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + for (method, params, expected_at_ms) in [ + ("item/started", &started, 1_700_000_000_123u64), + ("item/completed", &completed, 1_700_000_001_500u64), + ] { + let Some(CodexTurnEvent::Item { + completed, + params: event_params, + }) = direct_codex_notification_event(method, params, None, None, "turn-1") + else { + panic!("{method} 必须分类成条目生命周期事件"); + }; + let item = event_params.get("item").expect("item payload"); + assert_eq!( + direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + expected_at_ms, + "{method} 必须用通知层的阶段时间,而不是宿主钟" + ); + } + + let terminal = serde_json::json!({ + "threadId": "thread-1", + "turn": { + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }, + }); + let Some(CodexTurnEvent::Terminal(params)) = + direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1") + else { + panic!("turn/completed 必须分类成终态事件"); + }; + let turn = params.get("turn").unwrap_or(¶ms); + assert_eq!( + direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + 9_999, + "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" + ); + let with_duration = serde_json::json!({ + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000, + "durationMs + 宿主高精度起点才派生结束" + ); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { custom_enabled: false, @@ -6744,8 +6856,8 @@ done let mut assistant_items = Vec::new(); for event in &consumed.events { let item = match event { - DirectThreadEvent::ItemStarted { item } - | DirectThreadEvent::ItemCompleted { item } => item, + DirectThreadEvent::ItemStarted { item, .. } + | DirectThreadEvent::ItemCompleted { item, .. } => item, _ => continue, }; match item { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index e2086327e..1c27b262f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -15,7 +15,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 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 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-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 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-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -6008,6 +6008,13 @@ mod tests { assert!( prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") ); + // Canvas 居中责任唯一的合同必须真的进到实际 system prompt:Phaser autoCenter 与 + // CSS 居中二选一,且要求实测中心误差与构建 dist 复验,避免再次出现居中偏移。 + assert!(prompt.contains("Phaser 画布居中责任唯一")); + assert!(prompt.contains("不得在同一个 canvas 父容器上叠加")); + assert!(prompt.contains("必须把 Phaser autoCenter 设为 NO_CENTER")); + assert!(prompt.contains("不得用修改 AGC iframe 偏移来掩盖")); + assert!(prompt.contains("中心误差不超过 1 CSS px")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index a30816d82..5140f473c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -427,12 +427,16 @@ mod tests { } } + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + fn item_started(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_started(message(item_id)) + DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) } fn item_completed(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_completed(message(item_id)) + DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) } fn item_delta(item_id: &str) -> DirectThreadEvent { @@ -450,7 +454,7 @@ mod tests { #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); manager.append("thread-1", item_started("item-1")); @@ -474,7 +478,7 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); manager.append("thread-1", item_started("item-1")); manager.append("thread-1", item_delta("item-1")); manager.append("thread-1", item_completed("item-1")); @@ -484,7 +488,7 @@ mod tests { assert!(matches!( bootstrap.events.as_slice(), [ - DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::TurnStarted { .. }, DirectThreadEvent::ItemStarted { item, .. }, ] if item.item_id() == "item-2" )); @@ -589,15 +593,71 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadEvent::turn_completed("completed".to_string()), + DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + [DirectThreadEvent::TurnCompleted { status, at }] + if status == "completed" && *at == Some(FIXED_AT_MS) )); } + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + DirectThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first + .events + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + } + #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index f6b781218..e2f70a267 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -157,6 +157,23 @@ impl DirectThreadItem { | Self::Other { item_id, .. } => item_id, } } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } } /// 增量正文属于哪类条目。 @@ -201,18 +218,50 @@ impl DirectThreadRequestKind { /// /// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 /// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] - TurnStarted, + TurnStarted { + /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "turn.completed")] - TurnCompleted { status: String }, + TurnCompleted { + status: String, + /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.started")] - ItemStarted { item: DirectThreadItem }, + ItemStarted { + item: DirectThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.completed")] - ItemCompleted { item: DirectThreadItem }, + ItemCompleted { + item: DirectThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.delta")] ItemDelta { item_id: String, @@ -228,20 +277,23 @@ pub(crate) enum DirectThreadEvent { } impl DirectThreadEvent { - pub(crate) fn turn_started() -> Self { - Self::TurnStarted + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { at: Some(at) } } - pub(crate) fn turn_completed(status: String) -> Self { - Self::TurnCompleted { status } + pub(crate) fn turn_completed(status: String, at: u64) -> Self { + Self::TurnCompleted { + status, + at: Some(at), + } } - pub(crate) fn item_started(item: DirectThreadItem) -> Self { - Self::ItemStarted { item } + pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } } - pub(crate) fn item_completed(item: DirectThreadItem) -> Self { - Self::ItemCompleted { item } + pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } } pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { @@ -256,6 +308,19 @@ impl DirectThreadEvent { Self::Request { kind, request_id } } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + /// 事件关联的条目身份:只有 item 事件有。 pub(crate) fn item_id(&self) -> Option<&str> { match self { @@ -392,6 +457,63 @@ fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { observed_at_ms } +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn direct_thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn direct_thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + /// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { let call_id = item @@ -869,4 +991,231 @@ mod tests { DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" )); } + + /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, + /// 后者只说明条目什么时候被看到。 + #[test] + fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); + } + + #[test] + fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); + } + + #[test] + fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); + } + + #[test] + fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); + } + + #[test] + fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); + } + + /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, + /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 + /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 + #[test] + fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); + } + + #[test] + fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); + } + + /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 + /// 反序列化仍成立,且不会序列化出 `at: null`。 + #[test] + fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + DirectThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = serde_json::to_value(DirectThreadEvent::turn_completed( + "completed".to_string(), + 2_000, + )) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = + serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!(legacy, DirectThreadEvent::TurnStarted { at: None }); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(DirectThreadEvent::request( + DirectThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); + } } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bfd4cb80d..c9e0a01a2 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -242,6 +242,7 @@ import { applyDirectThreadConsumeResult, type DirectThreadChatState, emptyDirectThreadChatState, + finishDirectThreadTurn, mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, @@ -12185,8 +12186,12 @@ export function App({ const message = result?.message?.trim(); if (result?.outcome === 'released') { // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 - // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 - setDirectThreadChat((state) => ({ ...state, turnRunning: false })); + // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 + // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, + // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + setDirectThreadChat((state) => + finishDirectThreadTurn(state, Date.now()), + ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12396,6 +12401,7 @@ export function App({ chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} directTurnRunning={directCodexProductRuntime && directTurnRunning} + directTurnStartedAt={directThreadChat.turnStartedAt} directEntries={ directCodexProductRuntime ? selectDirectChatEntries(directThreadChat) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index dca93b24b..3bf77bfb4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -81,8 +81,28 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; import { formatClockTime, - formatTurnDuration, + resolveTurnTiming, } from './toolCallGroupPresentation'; +import { useLiveNow } from './useLiveNow'; + +/** + * 运行中整轮总耗时:自带 100ms 时钟的小叶子。 + * + * 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。 + * 起点拿不到时不渲染:不编造不能证明的耗时。 + */ +function TurnElapsedTotal({ startedAt }: { startedAt: number }) { + const now = useLiveNow(startedAt > 0); + const timing = resolveTurnTiming({ startedAt, running: true, now }); + if (!timing.durationText) { + return null; + } + return ( + + {`总耗时 ${timing.durationText}`} + + ); +} /** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ @@ -125,6 +145,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { directEntries?: DirectChatEntry[]; /** 最新回合是否还在跑;只由生命周期事件决定。 */ directTurnRunning?: boolean; + /** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */ + directTurnStartedAt?: number; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -195,6 +217,7 @@ export function ProjectSupervisorView({ directCodex = false, directEntries = [], directTurnRunning = false, + directTurnStartedAt = 0, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -246,8 +269,6 @@ export function ProjectSupervisorView({ const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 const [voiceNotice, setVoiceNotice] = useState(''); const [approvalOpen, setApprovalOpen] = useState(false); @@ -267,41 +288,55 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - useEffect(() => { - if (!directTurnRunning) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [directTurnRunning]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex ? buildDirectChatTurns({ entries: directEntries, localMessages: conversationMessages, turnRunning: directTurnRunning, + turnStartedAt: directTurnStartedAt, }) : []; const activeTurnStartedAt = directTurns.find((turn) => turn.active)?.startedAt ?? 0; + // 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染: + // - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位; + // - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。 + // 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。 + const initialSupervisorText = initialSupervisorMessage.trim(); + const initialSupervisorPlaceholderSuperseded = + hasEarlierConversationMessages || + (directCodex + ? directEntries.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) || conversationMessages.some((message) => message.role === 'user') + : conversationMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorText, + )); - const clockTimeWithSeconds = (timestamp: number) => { - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - }; - + /** + * 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染—— + * 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。 + */ const renderTurnUsage = (turn: DirectChatTurn) => { - if (turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); + if (turn.active) return null; + const timing = resolveTurnTiming({ + startedAt: turn.startedAt, + endedAt: turn.endedAt, + }); + if (!timing.durationText) return null; + const endedLabel = formatClockTime(turn.endedAt, { tenths: true }); return (

- {`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} + {endedLabel + ? `本轮结束于 ${endedLabel} · 总耗时 ${timing.durationText}` + : `总耗时 ${timing.durationText}`}

); }; @@ -435,12 +470,7 @@ export function ProjectSupervisorView({ : '显示更早的对话'} ) : null} - {initialSupervisorMessage.trim() && - !conversationMessages.some( - (message) => - message.role === 'user' && - message.text.trim() === initialSupervisorMessage.trim(), - ) ? ( + {initialSupervisorText && !initialSupervisorPlaceholderSuperseded ? (
@@ -651,15 +682,7 @@ export function ProjectSupervisorView({
) : null} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx index 683e5c2c0..64be2a863 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -6,26 +6,30 @@ import { Terminal, Wrench, } from 'lucide-react'; -import { useEffect, useId, useState } from 'react'; +import { useId, useState } from 'react'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; import type { DirectChatToolCard } from './directThreadChat'; import { formatToolCallDuration, - formatTurnDuration, + resolveTurnTiming, toolCallDurationMs, toolCallGroupSummary, toolCallInputText, toolCallRowText, - turnToolCallDurationMs, - turnToolCallTimeLabel, } from './toolCallGroupPresentation'; +import { useLiveNow } from './useLiveNow'; /** * 一回合的工具调用折叠块(Codex 风格): - * 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。 + * 块头一行汇总 + 整轮总耗时 + 时间范围,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。 * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 * + * 计时口径(总耗时与单条耗时各一套,都是 100ms 粒度、始终一位小数): + * - 总耗时属于**整轮**:从用户实际发送到明确终态,跨多个工具块共用同一组边界,包含 + * 没有工具的 LLM 等待;不是本块工具的时间跨度,本块工具全部结束后只要回合还在跑就继续增长。 + * - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。 + * * 无障碍:块头与每一行都是 `
@@ -168,14 +169,18 @@ export function ToolCallGroup({ function ToolCallRow({ call, active, + now, }: { call: DirectChatToolCard; active: boolean; + /** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */ + now: number; }) { const [expanded, setExpanded] = useState(false); const detailId = useId(); const text = toolCallRowText(call); - const durationMs = toolCallDurationMs(call); + const liveRunning = active && call.status === 'running'; + const durationMs = toolCallDurationMs(call, { now, running: liveRunning }); const durationText = formatToolCallDuration(durationMs); // "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理, // 否则用户会看到一条永远停在"执行中"的记录。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index 76e4a5fa3..aec57e18d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -28,12 +28,30 @@ export type DirectChatEntry = { role?: 'user' | 'assistant' | null; text?: string | null; toolCall?: DirectChatToolCard | null; + /** 条目展示时间(`item.at`);只用于气泡 / 条目的时间显示,不作任何计时的起止边界。 */ at?: number; + /** + * 本轮起点的展示元数据:原生 `turn.started.at`。 + * + * 只在该轮结束时盖到本轮条目上:回合收口后运行态清空,条目上的这组边界是本次会话里 + * 唯一还记得终态的载体。它是**本次会话的展示缓存**(不落盘、不进历史文件), + * 页面刷新后旧历史仍然没有边界、也不会因此显示出总耗时。不是第二套生命周期。 + */ + turnStartedAt?: number; + /** + * 本轮明确终态时间的展示元数据:`turn.completed.at`(或宿主终止收口的观测时间)。 + * 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。 + */ + turnEndedAt?: number; }; export type DirectThreadChatState = { /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ turnRunning: boolean; + /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ + turnStartedAt: number; + /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */ + turnEndedAt: number; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -43,11 +61,31 @@ export type DirectThreadChatState = { export function emptyDirectThreadChatState(): DirectThreadChatState { return { turnRunning: false, + turnStartedAt: 0, + turnEndedAt: 0, history: [], live: [], }; } +/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ +function validBoundaryAt(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : 0; +} + +/** + * 事件级阶段时间(毫秒):原生在 `turn.started` / `turn.completed` / `item.started` / + * `item.completed` 上给出的 `at`。 + * + * 它与条目里的 `item.at` 是两件事:后者是条目展示时间,不作工具计时的起止边界。 + * 字段由生成绑定声明(ts-rs),重放沿用原值,因此这里只读不取当前时间。 + */ +export function readDirectThreadEventAt(event: DirectThreadEvent): number { + return validBoundaryAt('at' in event ? event.at : 0); +} + function longerText( left: string | null | undefined, right: string | null | undefined, @@ -67,6 +105,29 @@ function mergeToolStatus( return left; } +/** 工具的终态:完成 / 失败一旦抵达就不再被后续快照改写。 */ +function isTerminalToolStatus( + status: DirectChatToolCard['status'] | null | undefined, +) { + return status === 'completed' || status === 'failed'; +} + +/** + * 工具终点只写一次:卡片已经拿到终态和终点时间之后,迟到 / 重放的事件不得把它抬高, + * 也不得把它抹掉;只有"还没冻结"时才允许用后到的、更晚的时间补上终点。 + */ +function mergeToolCardEndAt( + left: DirectChatToolCard, + right: DirectChatToolCard, +): number { + const leftUpdatedAt = Number.isFinite(left.updatedAt) ? left.updatedAt : 0; + if (isTerminalToolStatus(left.status) && leftUpdatedAt > 0) { + return leftUpdatedAt; + } + const rightUpdatedAt = Number.isFinite(right.updatedAt) ? right.updatedAt : 0; + return Math.max(leftUpdatedAt, rightUpdatedAt); +} + function mergeToolCard( left: DirectChatToolCard | null, right: DirectChatToolCard | null, @@ -87,7 +148,7 @@ function mergeToolCard( : right.detail.changes, }, startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, - updatedAt: Math.max(left.updatedAt, right.updatedAt), + updatedAt: mergeToolCardEndAt(left, right), }; } @@ -114,6 +175,9 @@ export function mergeDirectChatEntry( incoming.toolCall ?? null, ), at: existing.at || incoming.at, + // 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。 + turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt, + turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt, }; } @@ -157,22 +221,60 @@ export function reduceDirectThreadEvent( event: DirectThreadEvent, ): DirectThreadChatState { switch (event.type) { - case 'turn.started': - return { ...state, turnRunning: true }; - case 'turn.completed': - // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 + case 'turn.started': { + const eventAt = readDirectThreadEventAt(event); + // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 + // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, + // 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为 + // "还在跑时又收到 turn.started",那种情况保留第一次的起点。 + const turnStartedAt = + state.turnRunning && state.turnStartedAt > 0 + ? state.turnStartedAt + : eventAt; return { ...state, - turnRunning: false, - history: mergeHistoryEntries(state.history, state.live), - live: [], + turnRunning: true, + turnStartedAt, + turnEndedAt: 0, }; + } + case 'turn.completed': { + const eventAt = readDirectThreadEventAt(event); + // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 + if (!state.turnRunning && state.live.length === 0) { + return state; + } + return finishDirectThreadTurn(state, eventAt); + } case 'item.delta': return appendLiveText(state, event); case 'item.started': case 'item.completed': { - const entry = projectDirectThreadItem(event.item); - return entry ? upsertLiveEntry(state, entry) : state; + const eventAt = readDirectThreadEventAt(event); + const entry = projectDirectThreadItem( + event.item, + event.type === 'item.started' + ? { startedAt: eventAt } + : { completedAt: eventAt }, + ); + if (!entry) { + return state; + } + // 身份已经落盘过(同一个 itemId 只属于一个回合):这是迟到 / 重放的条目, + // 直接补进历史里同一条目(只补空字段、不改冻结的终点),不塞进运行态—— + // 否则它会挂到已经收口的那一轮之后的运行态里,被算进还没开始的新回合。 + const historyIndex = state.history.findIndex( + (existing) => existing.itemId === entry.itemId, + ); + if (historyIndex >= 0) { + const history = [...state.history]; + const existing = history[historyIndex]; + if (existing) { + history[historyIndex] = mergeDirectChatEntry(existing, entry); + return { ...state, history }; + } + } + return upsertLiveEntry(state, entry); } case 'request': // 审批 / 提问只影响面板交互,不并入聊天条目。 @@ -182,6 +284,61 @@ export function reduceDirectThreadEvent( } } +/** + * 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。 + * + * `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻): + * 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。 + * 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级 + * 由投影层决定(条目上的 `at` 才是气泡时间)。 + */ +export function finishDirectThreadTurn( + state: DirectThreadChatState, + endedAt: number | null | undefined, +): DirectThreadChatState { + const turnEndedAt = + state.turnEndedAt > 0 ? state.turnEndedAt : validBoundaryAt(endedAt); + const turnStartedAt = state.turnStartedAt; + const boundary = { + ...(turnStartedAt > 0 ? { turnStartedAt } : {}), + ...(turnEndedAt > 0 ? { turnEndedAt } : {}), + }; + const stamped = state.live.map((entry) => ({ ...entry, ...boundary })); + // 本轮的开口条目是 live 里那条用户消息;bootstrap 可能已经把用户消息当历史锚点发过, + // 这时 live 里只有过程条目。同一份边界只补到"历史最后一条、且是用户条目"上:那种位置 + // 只可能是本轮的开口条目(后面还没有任何内容),不会命中上一轮已经写完正文的开口条目。 + const history = + state.live.length > 0 && + !state.live.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) && + Object.keys(boundary).length > 0 + ? stampTrailingTurnOpener(state.history, boundary) + : state.history; + return { + ...state, + turnRunning: false, + turnStartedAt, + turnEndedAt, + history: mergeHistoryEntries(history, stamped), + live: [], + }; +} + +/** 历史最后一条正是本轮的开口条目时,补上同一份边界;否则原样返回。 */ +function stampTrailingTurnOpener( + history: DirectChatEntry[], + boundary: Pick, +): DirectChatEntry[] { + const last = history[history.length - 1]; + if (!last || last.kind !== 'message' || last.role !== 'user') { + return history; + } + const next = [...history]; + next[next.length - 1] = { ...last, ...boundary }; + return next; +} + export function reduceDirectThreadEvents( state: DirectThreadChatState, events: readonly DirectThreadEvent[], diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index 1f44ddda0..f51a4ad04 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -99,7 +99,6 @@ function fileChanges( type ToolCardInput = { itemId: string; - at: number; kind: GameCreatorDirectToolCallKind; /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ outputOnly?: boolean; @@ -110,7 +109,62 @@ type ToolCardInput = { changes?: GameCreatorDirectToolCallChange[]; }; -function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { +/** + * 条目的事件级阶段时间:`item.started` / `item.completed` 事件上的 `at`。 + * + * 工具卡片的时间边界**只认这里**:条目自身的 `item.at` 是展示时间,不能当起止。 + * 缺失(0)就留空:这个边界不知道,由展示层隐藏不能证明的耗时,不猜时间。 + */ +export type DirectItemPhaseTime = { + /** 收到 `item.started` 时该事件的时间;其它事件与历史切片不传。 */ + startedAt?: number; + /** 收到 `item.completed` 时该事件的时间;其它事件与历史切片不传。 */ + completedAt?: number; +}; + +/** 阶段时间合法性:缺失 / 0 / 非有限一律算"没有这个边界"。 */ +function phaseTime(value: number | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : 0; +} + +/** + * 阶段优先的状态:只有 `item.started` 阶段、而且条目本身没有终态信息时,工具就是"运行中"。 + * + * 有些条目(文件变更 / 联网检索 / 上下文整理 / 没有状态字段的 MCP 调用)type 上不带状态, + * 收到开始事件时不能一律当"已完成",否则它们在块里永远不会动态增长;真正结束由同身份的 + * `item.completed` 事件给出。条目自带的失败 / 完成状态优先,不被阶段改写。 + */ +function toolStatusForPhase( + status: GameCreatorDirectToolCallStatus, + phase: DirectItemPhaseTime, +): GameCreatorDirectToolCallStatus { + if ( + phaseTime(phase.startedAt) > 0 && + phaseTime(phase.completedAt) === 0 && + status === 'completed' + ) { + return 'running'; + } + return status; +} + +/** + * `function_call` 的时间只表示"这次调用什么时候发起":即使它是随 `item.completed` + * 到达的(调用条目本身完成了),那也不是工具结束——结束由 `function_call_output` 给出。 + * 因此这里只认起点,终点留空,不会伪造一个 0 秒的完成。 + */ +function callStartPhase(phase: DirectItemPhaseTime): DirectItemPhaseTime { + return { + startedAt: phaseTime(phase.startedAt) || phaseTime(phase.completedAt), + }; +} + +function buildToolCard( + input: ToolCardInput, + phase: DirectItemPhaseTime, +): DirectChatToolCard | null { const changes = input.changes ?? []; const detail: GameCreatorDirectToolCallDetail = {}; if (input.command && !input.outputOnly) detail.command = input.command; @@ -131,77 +185,99 @@ function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { kind: input.kind, title: input.outputOnly ? '' : toolTitle(input.kind, changes), summary: input.outputOnly ? '' : firstLine(summarySource), - status: input.status, + // 输出条目本身就是"结果快照",不因阶段被改成运行中。 + status: input.outputOnly + ? input.status + : toolStatusForPhase(input.status, phase), detail, - startedAt: input.at, - updatedAt: input.at, + startedAt: phaseTime(phase.startedAt), + // 终点只有一种来源:明确的完成事件时间。运行中的卡片拿不到终点(0), + // 由展示层用宿主当前时钟算增长,绝不用快照更新时间冒充"现在"。 + updatedAt: phaseTime(phase.completedAt), }; } -function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { +function toolCardFromItem( + item: DirectThreadItem, + phase: DirectItemPhaseTime, +): DirectChatToolCard | null { switch (item.itemType) { case 'function_call': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: toolKindFromFunctionName(item.name), - tool: item.name, - command: item.arguments, - status: 'running', - }); + return buildToolCard( + { + itemId: item.itemId, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }, + callStartPhase(phase), + ); case 'function_call_output': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'other', - outputOnly: true, - output: item.output, - status: 'completed', - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }, + phase, + ); case 'commandExecution': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'command', - command: item.command, - output: item.output ?? '', - status: toolStatus(item.status, item.exitCode), - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }, + phase, + ); case 'fileChange': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'file_change', - changes: fileChanges(item), - status: 'completed', - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }, + phase, + ); case 'mcpToolCall': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'mcp_tool', - tool: item.tool, - command: item.arguments, - output: item.output ?? '', - status: toolStatus(item.status, null), - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }, + phase, + ); case 'webSearch': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'web_search', - command: item.query ?? '', - output: item.output ?? '', - status: 'completed', - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }, + phase, + ); case 'contextCompaction': - return buildToolCard({ - itemId: item.itemId, - at: item.at, - kind: 'context_compaction', - command: '整理上下文', - status: 'completed', - }); + return buildToolCard( + { + itemId: item.itemId, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }, + phase, + ); default: return null; } @@ -215,6 +291,7 @@ function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { */ export function projectDirectThreadItem( item: DirectThreadItem | null | undefined, + phase: DirectItemPhaseTime = {}, ): DirectChatEntry | null { if (!item) return null; // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 @@ -254,7 +331,7 @@ export function projectDirectThreadItem( // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 return null; default: { - const toolCall = toolCardFromItem(item); + const toolCall = toolCardFromItem(item, phase); if (!toolCall) return null; return { itemId, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index a481c03ed..503a2d3c3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -45,7 +45,12 @@ export type DirectChatTurn = { /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ finals: DirectChatBlock[]; active: boolean; + /** + * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), + * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 + */ startedAt: number; + /** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */ endedAt: number; }; @@ -142,10 +147,16 @@ export function buildDirectChatTurns({ entries, localMessages = [], turnRunning = false, + turnStartedAt = 0, }: { entries: readonly DirectChatEntry[]; localMessages?: readonly ChatMessage[]; turnRunning?: boolean; + /** + * 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底, + * 不会覆盖用户实际发送时间,也不参与已完成回合。 + */ + turnStartedAt?: number; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; let current: DirectChatTurnEntries | null = null; @@ -223,20 +234,32 @@ export function buildDirectChatTurns({ const block = blockFromLocalMessage(message, index); if (block) finals.push(block); }); - const times = [ - ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), - ...turn.notices.map((message) => - normalizeDirectTimestamp(message.updatedAt), - ), - ...users.map((block) => (block.kind === 'user' ? block.at : 0)), - ].filter((at) => at > 0); - const startedAt = times.length ? Math.min(...times) : 0; - const endedAt = [ - ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), - ...turn.notices.map((message) => - normalizeDirectTimestamp(message.updatedAt), - ), - ].reduce((latest, at) => Math.max(latest, at), 0); + // 回合边界只认两件事:该轮用户气泡自己的发送时间(不是所有条目的最小值), + // 以及明确的终态事件时间。回合进行中先给"进行中"的滚动总耗时,结束后冻结。 + let userSentAt = 0; + for (const block of users) { + if (block.kind === 'user' && block.at > 0) { + userSentAt = block.at; + break; + } + } + const stampedStart = turn.entries.reduce( + (found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt), + 0, + ); + const stampedEnd = turn.entries.reduce( + (found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt), + 0, + ); + const startedAt = + userSentAt > 0 + ? userSentAt + : turn.active + ? normalizeDirectTimestamp(turnStartedAt) + : stampedStart; + // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 + // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 + const endedAt = turn.active ? 0 : stampedEnd; return { key: turn.key, users, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts index a4b0d7688..e085b1a43 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -11,12 +11,48 @@ import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; * * 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 * 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 + * + * 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) + * 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 + * `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 + * 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; + * 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 + * 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 + * `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, + * 不能在前端收到或重放时重新取当前时间。 */ export type DirectThreadEvent = - | { type: 'turn.started' } - | { type: 'turn.completed'; status: string } - | { type: 'item.started'; item: DirectThreadItem } - | { type: 'item.completed'; item: DirectThreadItem } + | { + type: 'turn.started'; + /** + * 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + */ + at?: number; + } + | { + type: 'turn.completed'; + status: string; + /** + * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + */ + at?: number; + } + | { + type: 'item.started'; + item: DirectThreadItem; + /** + * 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + */ + at?: number; + } + | { + type: 'item.completed'; + item: DirectThreadItem; + /** + * 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + */ + at?: number; + } | { type: 'item.delta'; itemId: string; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index e41ca0354..844d2601d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -173,97 +173,82 @@ function unwrapDisplayArgument(argument: string) { /** * 单条工具的耗时(毫秒)。 - * `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`: - * 这两种情况不显示耗时,不显示 `0s` / 负数。 + * + * 边界只来自事件级时间:`startedAt` 是 `item.started` 事件的时间,完成态用 + * `item.completed` 事件的时间。两者任一缺失(0 / 非有限)或时间倒序时返回 `null`: + * 没有开始事件的完成快照不猜开始时间,缺失与倒序都不显示耗时。 + * + * `running` 为真(该工具仍在跑、且所属回合仍在跑)时用 `now` 当终点:以时间戳差值 + * 计算,不按 tick 累加,后台节流后回到前台不会累计漂移。合法 0 是有效耗时,照常返回 0。 */ export function toolCallDurationMs( call: Pick, + options?: { now?: number; running?: boolean }, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; - const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; - if (startedAt <= 0 || updatedAt < startedAt) { + if (startedAt <= 0) { return null; } - return updatedAt - startedAt; + const endAt = options?.running + ? Number(options.now) + : Number.isFinite(call.updatedAt) + ? call.updatedAt + : 0; + if (!Number.isFinite(endAt) || endAt <= 0 || endAt < startedAt) { + return null; + } + return endAt - startedAt; } /** - * 单条工具的耗时文案:`0.4s`(<1s)/ `12.3s`(<60s,整秒省略小数)/ `2m 5s`(≥60s)。 - * 无法计算的耗时(`null` / 0 / 负数)返回 `null`。 + * 单条工具的耗时文案:始终一位小数,`0.0s` / `5.0s` / `1m 2.3s`。 + * 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0s`。 */ export function formatToolCallDuration(ms: number | null | undefined) { - if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) { return null; } - if (ms < 60000) { - const tenths = Math.max(1, Math.round(ms / 100)); - if (tenths < 600) { - const value = tenths / 10; - return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`; - } + const tenths = Math.round(ms / 100); + if (tenths < 600) { + return `${(tenths / 10).toFixed(1)}s`; } - const totalSeconds = Math.max(60, Math.round(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const restSeconds = totalSeconds % 60; - return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`; + const minutes = Math.floor(tenths / 600); + return `${minutes}m ${((tenths - minutes * 600) / 10).toFixed(1)}s`; } -/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ -export function turnToolCallDurationMs( - calls: Array>, -): number | null { - let minStartedAt = Number.POSITIVE_INFINITY; - let maxUpdatedAt = Number.NEGATIVE_INFINITY; - for (const call of calls) { - const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; - const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; - if (startedAt > 0) { - minStartedAt = Math.min(minStartedAt, startedAt); - } - if (updatedAt > 0) { - maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt); - } - } - if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) { - return null; - } - if (maxUpdatedAt < minStartedAt) { - return null; - } - return maxUpdatedAt - minStartedAt; -} - -/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */ +/** + * 整轮总耗时文案:始终一位小数,`0.0秒` / `5.0秒` / `1分钟 2.3秒`。 + * 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。 + */ export function formatTurnDuration(ms: number | null | undefined) { - if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) { + if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) { return null; } - const seconds = Math.max(1, Math.round(ms / 1000)); - if (seconds < 60) { - return `${seconds}秒`; + const tenths = Math.round(ms / 100); + if (tenths < 600) { + return `${(tenths / 10).toFixed(1)}秒`; } - const minutes = Math.floor(seconds / 60); - const restSeconds = seconds % 60; - return restSeconds === 0 - ? `${minutes}分钟` - : `${minutes}分钟 ${restSeconds}秒`; + const minutes = Math.floor(tenths / 600); + return `${minutes}分钟 ${((tenths - minutes * 600) / 10).toFixed(1)}秒`; } -/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ -export function turnToolCallEndedAt( - calls: Array>, +/** + * 时间戳取整到展示用的 100ms 网格(非法 / 缺失一律回 0)。 + * + * 时间范围与总耗时必须来自同一组取整边界:分别对原始毫秒做四舍五入会让 + * `14:20:05.9 → 14:20:06.1` 与 `0.1秒` 这类组合互相矛盾。 + */ +export function quantizeDisplayMs(value: number | null | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.round(value / 100) * 100 + : 0; +} + +/** 本地 `HH:mm:ss`;`tenths` 为真时补一位小数(`HH:mm:ss.S`)。缺失(0 / 非法)返回 `null`。 */ +export function formatClockTime( + timestamp: number | null | undefined, + options?: { tenths?: boolean }, ) { - let maxUpdatedAt = 0; - for (const call of calls) { - if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) { - maxUpdatedAt = call.updatedAt; - } - } - return maxUpdatedAt; -} - -/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */ -export function formatClockTime(timestamp: number | null | undefined) { if ( timestamp === null || timestamp === undefined || @@ -277,21 +262,91 @@ export function formatClockTime(timestamp: number | null | undefined) { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${hours}:${minutes}:${seconds}`; + if (!options?.tenths) { + return `${hours}:${minutes}:${seconds}`; + } + return `${hours}:${minutes}:${seconds}.${Math.floor(date.getMilliseconds() / 100)}`; } /** - * 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`); - * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 + * 整轮耗时(毫秒)= 本轮起点 → 本轮终态。 + * + * 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的 + * `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。 + * 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。 */ -export function turnToolCallTimeLabel( - calls: Array>, - userSentAt: number | null | undefined, -) { - const endLabel = formatClockTime(turnToolCallEndedAt(calls)); - if (!endLabel) { +export function turnTotalDurationMs({ + startedAt, + endedAt, + running = false, + now = 0, +}: { + /** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`;0 = 未知。 */ + startedAt: number | null | undefined; + /** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */ + endedAt?: number | null; + /** 本轮是否仍在跑:为真时用 `now` 当终点。 */ + running?: boolean; + now?: number; +}): number | null { + const rawEnd = running ? now : endedAt; + if ( + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) || + startedAt <= 0 || + typeof rawEnd !== 'number' || + !Number.isFinite(rawEnd) || + rawEnd <= 0 || + rawEnd < startedAt + ) { return null; } - const sentLabel = formatClockTime(userSentAt ?? 0); - return sentLabel ? `${sentLabel} → ${endLabel}` : endLabel; + const start = quantizeDisplayMs(startedAt); + const end = quantizeDisplayMs(rawEnd); + if (start <= 0 || end <= 0 || end < start) { + return null; + } + return end - start; +} + +export type DirectTurnTiming = { + /** 展示用总耗时(毫秒,已取整到 100ms 网格);null = 边界不完整,隐藏。 */ + durationMs: number | null; + /** `总耗时` 后面的文案;null = 隐藏。 */ + durationText: string | null; + /** `HH:mm:ss.S → HH:mm:ss.S`;起点未知时只给终点,两端都未知为 null。 */ + timeLabel: string | null; +}; + +/** + * 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。 + * + * 只认明确边界:缺字段、非有限、时间戳为 0、结束早于开始一律隐藏耗时(也不会退化成 + * `0.0秒`);合法的同一时刻(耗时 0)正常显示 `0.0秒`。 + */ +export function resolveTurnTiming({ + startedAt, + endedAt, + running = false, + now = 0, +}: { + startedAt: number | null | undefined; + endedAt?: number | null; + running?: boolean; + now?: number; +}): DirectTurnTiming { + const start = quantizeDisplayMs(startedAt); + const end = quantizeDisplayMs(running ? now : endedAt); + const durationMs = turnTotalDurationMs({ startedAt, endedAt, running, now }); + const startLabel = + start > 0 ? formatClockTime(start, { tenths: true }) : null; + const endLabel = end > 0 ? formatClockTime(end, { tenths: true }) : null; + return { + durationMs, + durationText: formatTurnDuration(durationMs), + timeLabel: + startLabel && endLabel + ? `${startLabel} → ${endLabel}` + : (endLabel ?? null), + }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts b/apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts new file mode 100644 index 000000000..172e8f1f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/useLiveNow.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +/** + * 动态计时的刷新粒度:总耗时与单条工具耗时都固定 100ms。 + * + * 这个 hook 是"有界时钟"的唯一实现:只在 `active` 为真时订阅定时器,停止 / 卸载即清理。 + * 调用方必须是叶子节点(一块工具卡片、一行进度),别在整棵 App 或整块面板上订阅, + * 否则每 100ms 会重建整个视图。 + */ +export const LIVE_TIMER_TICK_MS = 100; + +/** + * 每 100ms 返回一次宿主当前时间;`active` 为假时不订阅、也不再更新。 + * + * 读的是时间戳而不是累加 tick:后台被节流后回到前台,耗时按真实时间差补齐,不会漂移。 + * 返回的 `now` 在 `active` 为假时是上一次的值,调用方不得在非运行态把它当终点用。 + */ +export function useLiveNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) { + return undefined; + } + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), LIVE_TIMER_TICK_MS); + return () => clearInterval(timer); + }, [active]); + return now; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 98436f59b..561dfab11 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -1402,6 +1402,7 @@ function createProjectSupervisorRuntimeHarness({ * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 * * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + * 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。 */ completeDirectThreadTurn({ turnId, @@ -1419,10 +1420,11 @@ function createProjectSupervisorRuntimeHarness({ // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 const events: Array> = directThreadTurnRunning ? [] - : [{ type: 'turn.started' }]; + : [{ type: 'turn.started', at }]; if (prompt.trim()) { events.push({ type: 'item.completed', + at, item: { itemType: 'message', itemId: `direct-codex:${turnId}:user`, @@ -1434,6 +1436,7 @@ function createProjectSupervisorRuntimeHarness({ } events.push({ type: 'item.completed', + at: at + 1, item: { itemType: 'message', itemId: `direct-codex:${turnId}:assistant`, @@ -1443,7 +1446,8 @@ function createProjectSupervisorRuntimeHarness({ }, }); directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; - events.push({ type: 'turn.completed', status }); + // 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。 + events.push({ type: 'turn.completed', status, at }); emitDirectThreadEvents(...events); }, setDirectThreadHistory(items: Array>) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 441d57f3d..5d84ab530 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8062,21 +8062,26 @@ export function registerProjectSupervisorSurfaceTests() { expect(clientTurnId).not.toBe(''); // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 + // 事件级 `at` 是计时的唯一边界(原生在 item.started / item.completed 上给出), + // 条目里的 `item.at` 只是展示时间。用一个贴近宿主时钟的基准,运行中的总耗时才是真数值。 + const turnSentAt = Date.now(); await act(async () => { supervisorHarness.emitDirectThreadEvents( - { type: 'turn.started' }, + { type: 'turn.started', at: turnSentAt }, { type: 'item.completed', + at: turnSentAt, item: { itemType: 'message', itemId: `direct-codex:${clientTurnId}:user`, role: 'user', text: '做一个跑酷游戏', - at: 995, + at: turnSentAt, }, }, { type: 'item.started', + at: turnSentAt, item: { itemType: 'commandExecution', itemId: 'item-command', @@ -8113,6 +8118,7 @@ export function registerProjectSupervisorSurfaceTests() { supervisorHarness.emitDirectThreadEvents( { type: 'item.completed', + at: turnSentAt + 100, item: { itemType: 'commandExecution', itemId: 'item-command', @@ -8120,11 +8126,12 @@ export function registerProjectSupervisorSurfaceTests() { output: 'build ok', status: 'completed', exitCode: 0, - at: 1100, + at: turnSentAt + 100, }, }, { type: 'item.started', + at: turnSentAt + 50, item: { itemType: 'fileChange', itemId: 'item-file', @@ -8132,11 +8139,12 @@ export function registerProjectSupervisorSurfaceTests() { { path: 'game/src/hero.ts', kind: 'update' }, { path: 'game/src/hero.ts', kind: 'delete' }, ], - at: 1050, + at: turnSentAt + 50, }, }, { type: 'item.completed', + at: turnSentAt + 100, item: { itemType: 'fileChange', itemId: 'item-file', @@ -8144,7 +8152,7 @@ export function registerProjectSupervisorSurfaceTests() { { path: 'game/src/hero.ts', kind: 'update' }, { path: 'game/src/hero.ts', kind: 'delete' }, ], - at: 1100, + at: turnSentAt + 100, }, }, ); @@ -8159,8 +8167,11 @@ export function registerProjectSupervisorSurfaceTests() { )[0] as HTMLElement; const groupHead = within(group).getByTestId('agent-tool-call-group-head'); expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); - // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 - expect(group.getAttribute('data-duration-ms')).toBe('100'); + // 块头右侧是**整轮**总耗时(用户发送 → 回合终态),不是本块工具的时间跨度: + // 组内工具都结束了,但回合还在跑,块头仍标"进行中"、耗时继续增长(只断言它是一位小数的数值)。 + expect(group.getAttribute('data-status')).toBe('completed'); + expect(groupHead.textContent).toContain('进行中'); + expect(groupHead.textContent).toMatch(/总耗时 \d+\.\d+秒/); // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( @@ -8228,7 +8239,7 @@ export function registerProjectSupervisorSurfaceTests() { supervisorHarness.completeDirectThreadTurn({ turnId: clientTurnId, reply: 'DIRECT_REPLY:做一个跑酷游戏', - at: 1500, + at: turnSentAt + 500, }); }); await waitFor(() => { @@ -8259,6 +8270,12 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(settledHead.tagName).toBe('BUTTON'); expect(settledHead.getAttribute('aria-expanded')).toBe('false'); + // 回合收口后总耗时冻结在整轮边界上(用户发送 → turn.completed.at), + // 不再跟着墙上时钟走,也不是本块工具的时间跨度。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('500'); + expect(settledHead.textContent).toContain('总耗时 0.5秒'); + expect(settledHead.textContent).not.toContain('进行中'); const settledBody = settledGroup.querySelector( `#${settledHead.getAttribute('aria-controls')}`, ); @@ -8852,22 +8869,26 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); + // 计时只读事件级 `at`:用户发送这一刻就是整轮起点。 + const freshTurnSentAt = Date.now(); await act(async () => { // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 supervisorHarness.emitDirectThreadEvents( - { type: 'turn.started' }, + { type: 'turn.started', at: freshTurnSentAt }, { type: 'item.completed', + at: freshTurnSentAt, item: { itemType: 'message', itemId: `direct-codex:${clientTurnId}:user`, role: 'user', text: '做一个跑酷游戏', - at: 900, + at: freshTurnSentAt, }, }, { type: 'item.started', + at: freshTurnSentAt, item: { itemType: 'commandExecution', itemId: 'fresh-turn-command', @@ -8875,7 +8896,7 @@ export function registerProjectSupervisorSurfaceTests() { output: null, status: 'inProgress', exitCode: null, - at: 1000, + at: freshTurnSentAt, }, }, ); @@ -8893,6 +8914,9 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningHead.getAttribute('aria-expanded')).toBe('false'); expect(runningHead.textContent).toContain('1 个命令'); + // 回合还在跑:总耗时按"用户发送 → 现在"增长,一位小数、显示进行中。 + expect(runningHead.textContent).toContain('进行中'); + expect(runningHead.textContent).toMatch(/总耗时 \d+\.\d+秒/); const runningChildren = Array.from((messageList as HTMLElement).children); expect( runningChildren.findIndex((node) => node === runningGroup), @@ -8908,6 +8932,7 @@ export function registerProjectSupervisorSurfaceTests() { await act(async () => { supervisorHarness.emitDirectThreadEvents({ type: 'item.completed', + at: freshTurnSentAt + 400, item: { itemType: 'commandExecution', itemId: 'fresh-turn-command', @@ -8915,7 +8940,7 @@ export function registerProjectSupervisorSurfaceTests() { output: 'built in 400ms', status: 'completed', exitCode: 0, - at: 1400, + at: freshTurnSentAt + 400, }, }); }); @@ -8927,7 +8952,13 @@ export function registerProjectSupervisorSurfaceTests() { const settledRunningGroup = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', )[0] as HTMLElement; - expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + // 工具已经结束(块内没有 running 快照),但回合还在跑:块头仍标"进行中", + // 总耗时按"用户发送 → 现在"滚动(这里不钉具体毫秒,动态增长由 tool-call-group 用例覆盖)。 + expect(settledRunningGroup.getAttribute('data-status')).toBe('completed'); + expect( + within(settledRunningGroup).getByTestId('agent-tool-call-group-head') + .textContent, + ).toContain('进行中'); fireEvent.click( within(settledRunningGroup).getByTestId('agent-tool-call-group-head'), ); @@ -8946,7 +8977,7 @@ export function registerProjectSupervisorSurfaceTests() { supervisorHarness.completeDirectThreadTurn({ turnId: clientTurnId, reply: 'DIRECT_REPLY:空对话首轮', - at: 1500, + at: freshTurnSentAt + 1000, }); }); await waitFor(() => { @@ -8963,6 +8994,160 @@ export function registerProjectSupervisorSurfaceTests() { 'agent-tool-call-group', )[0] as HTMLElement; expect(settledGroup).toBeTruthy(); + // 回合收口:整轮总耗时冻结在用户发送 → turn.completed.at 的跨度上。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('1000'); + expect( + within(settledGroup).getByTestId('agent-tool-call-group-head') + .textContent, + ).toContain('总耗时 1.0秒'); + // 同一次收口也把总耗时写进回合小结,读的是同一组边界。 + expect( + within(supervisorSurface).getByTestId('turn-usage').textContent, + ).toContain('总耗时 1.0秒'); + }); + + it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => { + // 初始占位气泡只在"最初那条消息还没有正式条目"时顶位: + // Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`, + // 正式条目一到就必须撤掉占位,否则同一条消息会上下各出现一次(正式气泡 + 无时间占位)。 + const initialText = '做一个跑酷游戏'; + const baseProps = { + activeVersionId: null, + chatInput: '', + chatReferences: [], + chatProjectAssets: [], + composerRef: createRef(), + directCodex: true, + directTurnRunning: false, + hiddenConversationCount: 0, + messagesRef: createRef(), + needsUserInput: false, + onCancelConfirmation: vi.fn(), + onCancelPendingCommand: vi.fn(), + onChatInputChange: vi.fn(), + onConfirmConfirmation: vi.fn(), + onConfirmPendingCommand: vi.fn(), + onScroll: vi.fn(), + onShowEarlierMessages: vi.fn(), + onSubmit: vi.fn(), + pendingConfirmation: null, + pendingCommand: null, + projectPath: '/tmp/launcher-codex-placeholder-game', + transientReply: '', + visibleMessages: [], + visibleProfessionalAgentCards: [], + workspaceStatus: '等待指令', + planGddState: createPlanGddStateView(), + planGddHydrateBusy: false, + planGddDecisionBusy: false, + planGddError: null, + onPlanGddRefresh: vi.fn(), + onPlanGddDecision: vi.fn(), + runtime: null, + error: '', + runtimeByAgentId: {}, + controlBusy: false, + professionalResultsByAgentId: {}, + onToolAction: vi.fn(), + onSupervisorRetry: vi.fn(), + onProfessionalToolAction: vi.fn(), + onProfessionalRetry: vi.fn(), + onUserInput: vi.fn(), + initialSupervisorMessage: initialText, + }; + const initialUserBubbles = (surface: HTMLElement) => + Array.from(surface.querySelectorAll('.message--user')).filter((node) => + node.textContent?.includes(initialText), + ); + + // 正式条目还没到:占位顶上,只有一条。 + const pending = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + pending.unmount(); + + // 本地乐观气泡先于原生条目到达,也已接管初始占位。 + const optimistic = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + // 用户真实重复发送同文时仍保留两条,只撤下额外占位。 + optimistic.rerender( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + { + messageId: 'direct-codex:turn-2:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_970_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(2); + optimistic.unmount(); + + // 正式 Direct 条目到了:只剩正式气泡那一条,占位撤掉。 + const landed = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [ + { + itemId: 'direct-codex:turn-1:user', + kind: 'message' as const, + role: 'user' as const, + text: initialText, + at: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + landed.unmount(); + + // 已经翻出更早的历史:这里不是对话开头,不补最初那条消息。 + render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + hasEarlierConversationMessages: true, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(0); }); it.skip('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts index 8c4b44ab7..8b62e3d94 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -1,13 +1,17 @@ +import { act } from '@testing-library/react'; +import { vi } from 'vitest'; + import type { GameCreatorDirectToolCall } from '../../src/app/types'; import { ToolCallGroup } from '../../src/features/project-workspace/ToolCallGroup'; import { + formatClockTime, formatToolCallDuration, formatTurnDuration, + resolveTurnTiming, toolCallDurationMs, toolCallGroupSummary, toolCallRowText, - turnToolCallDurationMs, - turnToolCallTimeLabel, + turnTotalDurationMs, } from '../../src/features/project-workspace/toolCallGroupPresentation'; import { expect, fireEvent, it, React, render, within } from './harness'; @@ -219,12 +223,11 @@ export function registerToolCallGroupTests() { }); it('formats durations and turn totals across the documented boundaries', () => { - // 单条工具耗时:`startedAt` 缺失(0)/ 0 / 时间倒序 → 不显示耗时。 + // 单条工具耗时:开始边界缺失(0)/ 终点缺失 / 时间倒序 → 不显示耗时。 expect( toolCallDurationMs(toolCall({ id: 'a', kind: 'command' })), ).toBeNull(); expect(formatToolCallDuration(null)).toBeNull(); - expect(formatToolCallDuration(0)).toBeNull(); expect( toolCallDurationMs( toolCall({ @@ -235,45 +238,53 @@ export function registerToolCallGroupTests() { }), ), ).toBeNull(); - // <1s 一位小数;<60s 整秒省略小数;≥60s 用 `Xm Ys`。 + // 合法 0 显示 `0.0s`;两种耗时都始终一位小数。 + expect(formatToolCallDuration(0)).toBe('0.0s'); expect(formatToolCallDuration(400)).toBe('0.4s'); - expect(formatToolCallDuration(950)).toBe('1s'); + expect(formatToolCallDuration(950)).toBe('1.0s'); expect(formatToolCallDuration(12300)).toBe('12.3s'); - expect(formatToolCallDuration(12000)).toBe('12s'); - expect(formatToolCallDuration(125000)).toBe('2m 5s'); - expect(formatToolCallDuration(120000)).toBe('2m'); + expect(formatToolCallDuration(12000)).toBe('12.0s'); + expect(formatToolCallDuration(59900)).toBe('59.9s'); + expect(formatToolCallDuration(60000)).toBe('1m 0.0s'); + expect(formatToolCallDuration(125000)).toBe('2m 5.0s'); - // 一回合总用时 = min(startedAt) → max(updatedAt);缺时间戳的工具被跳过。 - const calls = [ - toolCall({ id: 'a', kind: 'command', startedAt: 5000, updatedAt: 6000 }), - toolCall({ - id: 'b', - kind: 'file_change', - startedAt: 1000, - updatedAt: 9000, - }), - toolCall({ id: 'c', kind: 'web_search' }), - ]; - expect(turnToolCallDurationMs(calls)).toBe(8000); - expect(formatTurnDuration(8000)).toBe('8秒'); - expect(formatTurnDuration(42000)).toBe('42秒'); - expect(formatTurnDuration(240000)).toBe('4分钟'); - expect(formatTurnDuration(345000)).toBe('5分钟 45秒'); + // 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。 + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000); + // 运行中用调用方给的宿主时钟当终点;结束早于开始 / 缺任一端都算不出来。 + expect( + turnTotalDurationMs({ startedAt: 1000, running: true, now: 4200 }), + ).toBe(3200); + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 900 })).toBeNull(); + // 倒序不能被 100ms 展示舍入掩盖成合法 0.0 秒。 + expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull(); + expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull(); + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull(); + // 总耗时文案:合法 0 显示 `0.0秒`,始终一位小数。 + expect(formatTurnDuration(0)).toBe('0.0秒'); + expect(formatTurnDuration(8000)).toBe('8.0秒'); + expect(formatTurnDuration(42000)).toBe('42.0秒'); + expect(formatTurnDuration(59900)).toBe('59.9秒'); + expect(formatTurnDuration(60000)).toBe('1分钟 0.0秒'); + expect(formatTurnDuration(240000)).toBe('4分钟 0.0秒'); + expect(formatTurnDuration(345000)).toBe('5分钟 45.0秒'); expect(formatTurnDuration(null)).toBeNull(); - expect(formatTurnDuration(0)).toBeNull(); - // 全部没有时间戳时算不出总用时。 - expect( - turnToolCallDurationMs([toolCall({ id: 'd', kind: 'command' })]), - ).toBe(null); + expect(formatTurnDuration(undefined)).toBeNull(); - // 块头时间:取得到用户消息时间就是「发送 → 结束」,取不到只显示结束时间,都取不到就不显示。 - expect(turnToolCallTimeLabel(calls, 1000)).toMatch( - /^\d{2}:\d{2}:01 → \d{2}:\d{2}:09$/, - ); - expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}:09$/); + // 时间范围与总耗时同一组边界:起点 / 终点都取整到 100ms 网格,精度不会互相矛盾。 + const timing = resolveTurnTiming({ startedAt: 1000, endedAt: 42000 }); + expect(timing.durationMs).toBe(41000); + expect(timing.durationText).toBe('41.0秒'); + expect(timing.timeLabel).toMatch(/^\d{2}:\d{2}:01\.0 → \d{2}:\d{2}:42\.0$/); + // 起点未知:只显示终点时间,耗时隐藏(不编造)。 + const noStart = resolveTurnTiming({ startedAt: 0, endedAt: 42000 }); + expect(noStart.durationMs).toBeNull(); + expect(noStart.durationText).toBeNull(); + expect(noStart.timeLabel).toMatch(/^\d{2}:\d{2}:42\.0$/); + // 两端都没有:整块不显示时间和耗时。 expect( - turnToolCallTimeLabel([toolCall({ id: 'e', kind: 'command' })], 0), - ).toBe(null); + resolveTurnTiming({ startedAt: 0, endedAt: 0 }).timeLabel, + ).toBeNull(); + expect(formatClockTime(0)).toBeNull(); }); it('renders per-row durations plus the turn total, and nothing when timestamps are missing', () => { @@ -303,23 +314,25 @@ export function registerToolCallGroupTests() { updatedAt: 17900, }), ], - userSentAt: 1000, + turnStartedAt: 1000, + turnEndedAt: 17900, }), ); const group = container.querySelector( '[data-testid="agent-tool-call-group"]', ) as HTMLElement; - // 总用时:1000 → 17900,块头显示「用时 17秒」,`data-duration-ms` 暴露原始毫秒。 + // 总耗时读**整轮**边界:1000 → 17900,块头显示「总耗时 16.9秒」, + // `data-duration-ms` 暴露取整到 100ms 网格后的展示耗时。 expect(group.getAttribute('data-duration-ms')).toBe('16900'); const head = within(group).getByTestId('agent-tool-call-group-head'); - expect(head.textContent).toContain('用时 17秒'); - expect(head.getAttribute('aria-label')).toBe( - '已执行 1 个命令、1 个文件变更、1 个联网搜索,用时 17秒', + expect(head.textContent).toContain('总耗时 16.9秒'); + expect(head.getAttribute('aria-label')).toMatch( + /^已执行 1 个命令、1 个文件变更、1 个联网搜索,\d{2}:\d{2}:\d{2}\.\d → \d{2}:\d{2}:\d{2}\.\d,总耗时 16\.9秒$/, ); - // 时间戳不写死时区:`HH:mm:ss → HH:mm:ss`(发送 → 结束)。 + // 时间戳不写死时区:`HH:mm:ss.S → HH:mm:ss.S`(发送 → 结束)。 expect( head.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); + ).toMatch(/^\d{2}:\d{2}:\d{2}\.\d → \d{2}:\d{2}:\d{2}\.\d$/); fireEvent.click(head); const rows = within(group).queryAllByTestId('agent-tool-call-row'); @@ -327,9 +340,9 @@ export function registerToolCallGroupTests() { expect(within(rows[0] as HTMLElement).getByText('0.4s')).not.toBeNull(); expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500'); expect(within(rows[1] as HTMLElement).getByText('16.5s')).not.toBeNull(); - // startedAt === updatedAt:耗时为 0 —— `data-duration-ms` 如实暴露 0,但行上不显示 `0s`。 + // startedAt === updatedAt:合法 0,按契约显示 `0.0s`。 expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0'); - expect(within(rows[2] as HTMLElement).queryByText('0s')).toBeNull(); + expect(within(rows[2] as HTMLElement).getByText('0.0s')).not.toBeNull(); // 时间只显示在块头,展开后不重复追加块尾时间。 expect( within(group).queryByTestId('agent-tool-call-group-end-time'), @@ -361,4 +374,132 @@ export function registerToolCallGroupTests() { missingGroup.querySelector('.agent-tool-call-group-end-time'), ).toBeNull(); }); + + /** + * 动态计时:两套耗时都每 100ms 刷新、始终一位小数。 + * + * 假时钟同时接管 `Date.now()`:`useLiveNow` 读时间戳算差值,不按 tick 累加。 + */ + it('grows the turn total every 100ms with one decimal and freezes it when the turn ends', () => { + vi.useFakeTimers(); + const startedAt = Date.now(); + const calls = [ + toolCall({ + id: 'live-a', + kind: 'command', + summary: 'npm run build', + status: 'running', + startedAt, + }), + toolCall({ + id: 'live-b', + kind: 'web_search', + summary: '玩法调研', + status: 'running', + startedAt: startedAt + 400, + }), + ]; + const view = render( + React.createElement(ToolCallGroup, { + calls, + turnStartedAt: startedAt, + active: true, + }), + ); + const group = view.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + const head = within(group).getByTestId('agent-tool-call-group-head'); + expect(head.textContent).toContain('进行中'); + expect(head.textContent).toContain('总耗时 0.0秒'); + expect(group.getAttribute('data-duration-ms')).toBe('0'); + + // 没有新事件,时间推进总耗时也增长;5000ms 后是 `5.0秒`。 + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(group.getAttribute('data-duration-ms')).toBe('5000'); + expect(head.textContent).toContain('总耗时 5.0秒'); + // 分钟进位:65000ms → `1分钟 5.0秒`。 + act(() => { + vi.advanceTimersByTime(60000); + }); + expect(head.textContent).toContain('总耗时 1分钟 5.0秒'); + + // 回合收口:总耗时冻结在终态时间上,继续推进时钟不再变化。 + const frozenEnd = startedAt + 65000; + view.rerender( + React.createElement(ToolCallGroup, { + calls, + turnStartedAt: startedAt, + turnEndedAt: frozenEnd, + active: false, + }), + ); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(group.getAttribute('data-duration-ms')).toBe('65000'); + expect(head.textContent).toContain('总耗时 1分钟 5.0秒'); + expect(head.textContent).not.toContain('进行中'); + view.unmount(); + vi.useRealTimers(); + }); + + it('keeps every group of the same turn on the same boundary and freezes finished tools', () => { + vi.useFakeTimers(); + const startedAt = Date.now(); + const runningCall = toolCall({ + id: 'running-call', + kind: 'command', + summary: 'npm run build', + status: 'running', + startedAt: startedAt, + }); + const finishedCall = toolCall({ + id: 'finished-call', + kind: 'file_change', + summary: 'game/src/hero.ts', + startedAt: startedAt, + updatedAt: startedAt + 1000, + }); + // 同一轮的第二个工具块(中间夹了思考):共用整轮边界,各自从头读同一个起点。 + const firstGroup = render( + React.createElement(ToolCallGroup, { + calls: [runningCall], + turnStartedAt: startedAt, + active: true, + }), + ); + const secondGroup = render( + React.createElement(ToolCallGroup, { + calls: [finishedCall], + turnStartedAt: startedAt, + active: true, + }), + ); + act(() => { + vi.advanceTimersByTime(3000); + }); + const first = firstGroup.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + const second = secondGroup.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + expect(first.getAttribute('data-duration-ms')).toBe('3000'); + expect(second.getAttribute('data-duration-ms')).toBe('3000'); + // 已完成的工具立刻冻结:不跟着整轮时钟继续增长。 + fireEvent.click(within(second).getByTestId('agent-tool-call-group-head')); + const finishedRow = within(second).getByTestId('agent-tool-call-row'); + expect(finishedRow.getAttribute('data-duration-ms')).toBe('1000'); + // 运行中的工具按当前时钟继续增长。 + fireEvent.click(within(first).getByTestId('agent-tool-call-group-head')); + const runningRow = within(first).getByTestId('agent-tool-call-row'); + expect(runningRow.getAttribute('data-duration-ms')).toBe('3000'); + + firstGroup.unmount(); + secondGroup.unmount(); + vi.useRealTimers(); + }); } diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index d9bb2b82f..46de8b4e0 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { emptyDirectThreadChatState, + finishDirectThreadTurn, mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, @@ -211,4 +212,204 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.status).toBe('completed'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + + describe('事件级计时边界', () => { + /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ + it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ + type: 'item.started', + at: 1_000_100, + item: toolStarted({ at: 999_999 }), + }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput({ at: 1_200_000 }), + }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(1_000_100); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('只有完成事件、没有开始事件的工具不猜开始时间', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(0); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { + const fileChange = { + itemType: 'fileChange', + itemId: 'file-1', + changes: [{ path: 'game/src/hero.ts', kind: 'update' }], + at: 0, + } as const; + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: fileChange }), + ]); + const startedCall = selectDirectChatEntries(started)[0]?.toolCall; + expect(startedCall?.status).toBe('running'); + expect(startedCall?.startedAt).toBe(1_000_100); + expect(startedCall?.updatedAt).toBe(0); + + const finished = reduceDirectThreadEvents(started, [ + event({ type: 'item.completed', at: 1_000_900, item: fileChange }), + ]); + const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall; + expect(finishedCall?.status).toBe('completed'); + expect(finishedCall?.updatedAt).toBe(1_000_900); + }); + + it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + // 同一个 function_call 又以完成快照重放一次:不是工具结束。 + event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }), + ]); + const running = selectDirectChatEntries(state)[0]?.toolCall; + expect(running?.status).toBe('running'); + // 完成快照不改开始边界,也不产生终点。 + expect(running?.startedAt).toBe(1_000_100); + expect(running?.updatedAt).toBe(0); + + const done = reduceDirectThreadEvents(state, [ + event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }), + ]); + const finished = selectDirectChatEntries(done)[0]?.toolCall; + expect(finished?.status).toBe('completed'); + expect(finished?.updatedAt).toBe(1_000_900); + }); + + it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + expect(done.turnRunning).toBe(false); + expect(done.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + + const replayed = reduceDirectThreadEvents(done, [ + // 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。 + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + }); + + it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" + // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 + event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + ]); + const next = reduceDirectThreadEvents(done, [ + event({ type: 'turn.started', at: 1_000_000 }), + ]); + expect(next.turnRunning).toBe(true); + expect(next.turnStartedAt).toBe(1_000_000); + expect(next.turnEndedAt).toBe(0); + }); + + it('同一轮内的重复开始事件保留第一次的起点', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_500 }), + ]); + expect(started.turnRunning).toBe(true); + expect(started.turnStartedAt).toBe(1_000_000); + }); + + it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + // 迟到的事件:同身份的完成快照在回合收口之后才到。 + const late = reduceDirectThreadEvents(done, [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + expect(late.live).toHaveLength(0); + expect(late.history).toHaveLength(1); + expect(late.history[0]?.toolCall?.status).toBe('completed'); + expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame'); + // 冻结的终点不被抬高。 + expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + + // 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。 + const next = reduceDirectThreadEvents(late, [ + event({ type: 'turn.started', at: 1_001_000 }), + event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }), + ]); + expect(next.live).toHaveLength(0); + expect(next.history).toHaveLength(1); + }); + + it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => { + const bootstrapped = resolveDirectThreadBootstrap( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }), + ]), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-user', + events: [ + // 运行态里只有过程条目:开口条目已经在历史里。 + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ + type: 'turn.completed', + status: 'completed', + at: 1_000_900, + }), + ], + }, + ); + expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe( + 1_000_900, + ); + }); + + it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const released = finishDirectThreadTurn(running, Date.now()); + expect(released.turnRunning).toBe(false); + expect(released.live).toHaveLength(0); + expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt); + + const unknown = finishDirectThreadTurn(running, 0); + expect(unknown.turnEndedAt).toBe(0); + expect(unknown.history[0]?.turnEndedAt).toBeUndefined(); + // 已经收口:后续事件不再改动终态时间,也不复活运行态。 + const replayed = reduceDirectThreadEvents(unknown, [ + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(0); + }); + }); }); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 809041ff9..054ecae37 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -55,6 +55,31 @@ const toolEntry = ( }, }); +/** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */ +const liveToolEntry = ( + itemId: string, + startedAt: number, + updatedAt: number, + status: 'running' | 'completed' = 'completed', +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status, + detail: { command: 'npm run build' }, + startedAt, + updatedAt, + }, +}); + const reasoningEntry = ( itemId: string, text = '先看目录', @@ -104,7 +129,8 @@ describe('DirectProject 聊天分区', () => { expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); expect(turns[0]?.startedAt).toBe(1_800_000_000_000); - expect(turns[0]?.endedAt).toBe(1_800_000_001_000); + // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。 + expect(turns[0]?.endedAt).toBe(0); }); it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { @@ -166,6 +192,59 @@ describe('DirectProject 聊天分区', () => { ]); }); + it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => { + const turns = buildDirectChatTurns({ + entries: [ + // 两个旧回合:历史切片里没有事件级边界。 + userEntry('u1', 1_800_000_000_000), + assistantEntry('a1', '第一轮答复', 1_800_000_001_000), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + // 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。 + { + ...userEntry('u3', 1_800_000_020_000), + turnEndedAt: 1_800_000_022_500, + }, + liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000), + { + ...assistantEntry('a3', '第三轮答复', 1_800_000_022_400), + turnStartedAt: 1_800_000_020_000, + turnEndedAt: 1_800_000_022_500, + }, + ], + }); + expect(turns.map((turn) => turn.endedAt)).toEqual([ + 0, 0, 1_800_000_022_500, + ]); + // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。 + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[2]?.startedAt).toBe(1_800_000_020_000); + }); + + it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => { + const liveTurn = buildDirectChatTurns({ + entries: [ + { ...userEntry('u1', 0), at: 0 }, + liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), + ], + turnRunning: true, + turnStartedAt: 1_800_000_000_100, + }); + // 用户条目没有发送时间:用原生 turn.started.at 兜底。 + expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100); + + const withUserTime = buildDirectChatTurns({ + entries: [ + userEntry('u1', 1_800_000_000_050), + liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), + ], + turnRunning: true, + turnStartedAt: 1_800_000_000_100, + }); + // 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。 + expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050); + }); + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { const turns = buildDirectChatTurns({ entries: [userEntry('u1'), assistantEntry('a1', '正文')], diff --git a/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md b/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md new file mode 100644 index 000000000..ac310bad5 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md @@ -0,0 +1,33 @@ +# 【实施计划】对话总耗时与动态工具计时 + +| 字段 | 值 | +| --- | --- | +| Status | implemented-awaiting-runtime-acceptance | +| Milestone | docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md | + +## 修改边界与顺序 + +1. 独立评审主规范与里程碑后进入实现。当前问题是工具组窗口、整轮范围和运行快照混用;不能只把 setInterval 从 1000 改成 100。 +2. 原生在现有生命周期事件上提供阶段时间:`turn.started` / `turn.completed` 的事件级 `at`,以及 `item.started` / `item.completed` 的事件级 `at`,不是更改条目嵌套的 `item.at`。工具计时只消费阶段事件时间,嵌套 `item.at` 继续承载条目展示时间,不当作真实开始/完成。开始/完成事件优先读取对应上游阶段字段或时长,缺失时在宿主事件产生时取钟。TypeScript 可选数值字段处理既有历史/无时间夹具,真实宿主始终提供时间;ts-rs 生成绑定,按仓库 u64→number 约定处理,不手改。 +3. 前端事件 reducer 保留工具开始、完成边界以及本轮终态时间;完成边界可附在本轮聊天条目展示元数据上,沿用现有用户条目划分回合,不新增 turnId 或第二套生命周期。无时间旧历史不编造准确总耗时。 + - 整轮起点:该轮用户消息的实际发送时间优先,缺失才取 `turn.started.at`;与用户要求的“发送到完成”一致,不能改为第一条工具的起点。终点只取明确终态事件,不能取最后一条正文的创建时间或最后工具更新时间。 + - 只有完成没有开始事件的工具隐藏未知耗时;缺失、0、非有限或倒序边界隐藏,正常运行的零时长显示 `0.0`。 +4. 视图传递同轮起止时间到各工具块,以共享或局部有界的 100ms 时钟驱动展示,不把整棵 App 状态每 100ms 重建。只在有运行中计时目标时订阅,结束/切项目/卸载清理。 +5. 测试先覆盖静止事件输入下推进时钟、工具完成但回合仍运行、终态与重复事件;补阶段字段与回放时间测试。独立 review 后主 Agent 核对并返修。 +6. 同视图的追加局部修复:初始消息占位判定应读取实际 Direct 用户条目/乐观用户条目和分页状态,不只检查旧 conversationMessages;增加正式气泡只出现一次、合法同文消息不被去重的回归。 + +## 并行写入边界 + +- 原生实现者:`src-tauri/src/agent/direct_thread_wire.rs`、`codex_app_server/mod.rs`、必要的 `direct_thread_manager.rs` 模式匹配/测试及 ts-rs 生成绑定。 +- 前端实现者:`directThreadChat.ts`、`directThreadItemProjection.ts`、`directTurnPresentation.ts`、`ToolCallGroup.tsx`、`toolCallGroupPresentation.ts`、`ProjectSupervisorView.tsx`、必要的 App 接线与对应测试。不得修改原生或生成绑定。 +- 主 Agent:文档、集成验证、独立 review 决策与浏览器 smoke;不得覆盖其他人的未提交文件。 + +## 验证与风险 + +前端使用已有 `.app/merge-deps` 隔离配置,不修改共享 node_modules。运行定向 Vitest、AGC 类型/skill/config 检查、Rust wire/manager 相关测试与 all-targets offline check、编码/文档索引/diff 检查。历史缺少完整时间时隐藏无法证明的用时,不迁移历史文件。原生协议变更需重新启动客户端才能生效,不擅自重启用户正在运行的回合。 + +回滚点为本次改动前的 `1bd153d7f`;若失败只回退本次文件变更,不动用户锁文件和其它分支。 + +## 收口 + +已完成本轮实现、独立 review 返修与自动化/模拟浏览器验证,证据见对应里程碑。用户已授权更新 Issue/PR 并推送当前功能分支;不得据此合并 PR、发布或恢复定时任务。真实客户端端到端验收尚待完成,因此保留计划,卡片全面重设计和画布内任务浮层不计入本轮已交付内容。 diff --git a/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md b/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md new file mode 100644 index 000000000..fe4b35c88 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md @@ -0,0 +1,35 @@ +# 【里程碑】对话总耗时与动态工具计时 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented-awaiting-runtime-acceptance | +| Date | 2026-09-18 | +| Parent Spec | docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md | + +## 目标与边界 + +工具组显示整轮请求总耗时,单条显示自身调用耗时,运行中均以 100 毫秒粒度动态增长并保留一位小数。沿用 Thread Manager,不新增业务状态源或计时账本。保留既有画布功能和本地未提交锁文件;用户后续已授权更新 Issue/PR 与推送本分支,不合并 PR、不发布、不写飞书、不调用真实付费 Provider。 + +## 验收 + +前置独立评审已完成:事件级时间与条目展示时间分离、缺失/倒序判据、章节结构及格式示例已明确。主 Agent 决定整轮起点以用户发送为优先、原生 turn.started 为缺失兜底,不采用会排除发送后准备时间的起点替换。 + +- [x] 总耗时从用户发送到回合终态,跨多个工具块共享边界,包含无工具运行时的 LLM 等待。 +- [x] 工具运行中无事件亦增长,已完成工具与完成回合均冻结,失败/终止通过既有明确收口路径冻结。 +- [x] 原生开始/完成时间不混用,事件重放不重新取当前时间。 +- [x] 重复、迟到条目不抬高已冻结用时;缺失/倒序时间不伪造耗时。同秒新回合仍按事件顺序正常开始。 +- [x] 一位小数、分钟进位、并行调用与卸载清理有定向/浏览器证据。 +- [x] 类型检查、Rust 定向测试与 all-targets check、编码/文档索引及 diff 检查通过。 +- [x] 模拟事件的实际浏览器验证动态增长与终态冻结;未调用真实 Provider。 +- [x] 正式 Direct 用户条目或本地乐观条目出现后撤下初始占位,真实同文重复发送仍保留。 +- [ ] 真实客户端切项目、取消后立即重发、重开历史及真实 Provider 通知链路验收。 + +## 验证与剩余风险 + +- appSurface 与 reducer/回合投影三文件合计 493 项通过、17 项跳过;最终初始占位与计时边界补丁另跑 5 项通过。 +- 隔离依赖配置下 AGC TypeScript、skill/config、ESLint、编码与文档索引检查通过;Rust all-targets locked offline check 通过。 +- 原生实现者执行 direct_thread 35 项、direct_ 312 项(1 跳过)、codex_app_server 58 项(1 跳过)及系统工程提示定向用例通过,过滤集合有重叠,不能相加作为独立用例总数。 +- Edge 无头真实浏览器使用生产组件与 reducer、模拟生命周期事件:无新事件仍每 100ms 增长,单工具完成后冻结,另一工具与整轮继续增长,回合完成及重复完成事件不改变最终值。 +- 无上游精确阶段时间时使用宿主观测毫秒,不宣称测得模型内部物理耗时;旧历史缺少完整生命周期时间时隐藏未知用时。 +- 此证据不代表远程 CI 全绿。此前 PR 当前已推送提交的 run 2473 有两条 Rust shard 2 失败,仍需单独排查及后续 SHA 的远程结果。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f6651d3b1..1370d0d83 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## Phaser 与 CSS 双重居中导致游戏画面偏移 + +Phaser `Scale.FIT` 与 `autoCenter: CENTER_BOTH` 会给 canvas 计算定位外边距。若 canvas 的直接父容器同时使用 `display: grid; place-items: center` 或另一套 CSS 居中,浏览器再次定位带 margin 的元素,竖屏游戏会相对预览区域偏右。应只保留一个居中责任方:Phaser 居中时直接父容器使用尺寸明确的普通块布局;CSS 居中时设置 `autoCenter: NO_CENTER`。不禁止外围页面的 Grid/Flex 布局,不通过修改 AGC iframe 的固定偏移掩盖项目 CSS 问题。 + +开发 Agent 的实际系统工程提示和 `agc-web-game-development` Skill 均包含此规则。布局修改后重新构建 dist,分别在桌面、移动与 resize 后测量 canvas 相对游戏父容器的中心误差(预期居中时不超过 1 CSS px),同时检查无溢出和意外滚动条;构建成功不等于视觉验收通过。 + ## AGC 空快照测试必须等待请求完成 `waitFor(() => expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8232e92d7..19fbb19b6 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -16,6 +16,8 @@ ## 开发中 +- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。 + - AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 683bbd7c4..fe7949004 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,9 @@ # AI 游戏创作智能体 App 实施计划 +## 游戏画布居中指引 + +开发 Agent 的系统工程提示与 `agc-web-game-development` Skill 明确约束同一 canvas 的居中只能由一方负责:Phaser `FIT + CENTER_BOTH` 配合尺寸明确的普通块级父容器,不叠加同一父容器的 Grid/Flex 居中、自动外边距或居中 transform;如由 CSS 居中则设置 Phaser `NO_CENTER`,外围页面的 Grid/Flex 不受此限制。布局修改后构建实际 dist,并在桌面、移动和 resize 下核对 canvas 对游戏父容器中心偏差不超过 1 CSS px、无溢出与意外滚动条。出现偏移先检查游戏项目的 CSS/scale,不修改 AGC 预览固定偏移掩盖问题;这些要求通过 Agent 指引执行,不新增运行时门禁或平行校验系统。 + ## 多选素材批量标签 - 画布与资源面板共用选中集合。选择至少两项同项目已登记素材后,从已有“编辑标签”入口或资源面板的“批量标签”动作打开同一标签编辑器的批量模式;入口遵循既有“常用操作 + 更多”收纳,不新增平行资源管理页。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 886772b12..9f02873a5 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -11,7 +11,22 @@ ## 一句话交付 -把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。 +把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,顶部显示整轮请求总耗时,内部工具显示各自耗时;运行中的计时动态增长并保留一位小数。 + +## 总耗时与动态工具计时 + +- 工具块顶部的“总耗时”表示同一轮用户请求从发送到回合终态的墙钟跨度,包含 LLM 推理、工具调用和等待;不是工具耗时之和,也不是当前工具块的时间跨度。思考或正文将工具拆成多组时,各组读取同一轮的时间边界,不能各自重置起点。 +- 回合仍在运行时,即使当前工具块内的工具已全部结束,总耗时仍持续增加。成功、失败或终止到达后按实际终态时间固定;随后打开折叠块、翻页或其它回合的新事件不得继续改变该轮用时。 +- 单条工具从其实际开始到完成计时。运行中的工具按当前时间持续增长,不能把最近一次快照更新时间当作当前时间;已经结束的工具必须立即固定,即使同组其它工具或 LLM 仍在运行。 +- 两种耗时均每 100 毫秒刷新,始终保留一位小数(例如 `0.0秒`、`5.1秒`、`1分钟 2.3秒`,单条可沿用 `5.1s` / `1m 2.3s` 的紧凑格式)。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`。 +- 时间范围与总耗时使用同一轮的起止边界。运行中显示“进行中”;完成后如果展示起止时间,其精度不得造成范围差与总耗时矛盾。 +- 原生事件保留各阶段的时间语义:开始与完成不能都优先折叠成开始时间。优先采用上游明确提供的阶段时间或实际调用时长,缺失时使用宿主观察该阶段的时间;重放沿用原事件时间,不能在前端收到或重放时重新取当前时间。 +- 工具开始/完成的权威边界是事件级 `at`,不是条目展示字段 `item.at`。整轮起点优先采用该轮实际用户消息的发送时间(与气泡一致,不取所有条目的最小时间),缺失时采用原生 `turn.started.at`;终点只采用 `turn.completed.at` 或明确的终止/失败收口事件。首次补到更早的真实发送时间可以校正起点,但旧历史或重复事件不能覆盖已经固定的终点。 +- 回合阶段使用宿主观测阶段的毫秒时间,或上游明确的毫秒边界/可靠时长;不把上游已截断的整秒时间冒充十分之一秒精度。回合是否开始以 Thread Manager 的事件顺序为准,不能用时间戳大小拒绝取消后同一秒内的新请求。回合完成的展示时间在当前会话中冻结;重新打开后若历史未保存完整生命周期边界,则隐藏未知用时而非补造。 +- 无效边界包括缺字段、非有限值、时间戳为 0、结束早于开始;这些情况隐藏不能证明的耗时。合法开始时刻的运行中 `0.0秒` / `0.0s` 则正常显示。只有完成快照而没有开始事件的工具不猜测开始时间。 +- 沿用 Thread Manager 的生命周期与条目身份,不引入第二套回合状态源、计时账本、远程 API 或数据库字段。前端仅保存原事件的展示时间边界;旧历史缺少完整边界时不声称知道精确总耗时或工具耗时,不为补齐计时启动模型请求。 +- 必须覆盖:两组工具共用整轮起点、无新事件仍增长、工具完成后 LLM 继续、并行工具分别计时、完成/失败/终止冻结、同身份重放不改终态时间、切项目与卸载停止计时、缺失与倒序时间、0.0 / 59.9 / 60.0 秒边界。真实 Provider 验证与模拟事件的客户端验证分开报告。 +- Direct 对话的初始消息占位仅在正式用户消息尚未进入当前显示链路时显示;正式条目或乐观用户条目出现后撤下占位,不在消息列表外额外保留一份。只控制占位是否显示,不按文本合并或删除用户真实重复发送的消息;分页已有更早历史时不把初始占位补到当前页。 ## 背景与现状(已核实) @@ -74,11 +89,11 @@ DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读 ```html