From 733a7015afd664f5d744121d805ef8f581a7a248 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 16:02:37 +0800 Subject: [PATCH 01/34] =?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 02/34] =?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 03/34] =?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 04/34] =?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 05/34] =?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 06/34] =?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 07/34] =?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 08/34] =?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 本体化资源卡交互与性能合同 -- 卡片默认可视区域不再显示文件名、资源名称、来源、路径、任务和媒体类型等详细文本。这些字段继续进入搜索索引和中央详情;卡片“打开详情”入口的可访问名称必须包含稳定可辨识的资源名与类别。 +- 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。 +- 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。 +- 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。 - 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 ` +
+ ); +} 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 d222aad2ec2c1e071fe847e2ad42e45ea147fa5e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:24:33 +0800 Subject: [PATCH 15/34] =?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 16/34] =?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 7f80012d7f12cca8348225c827ddc5479b35495e Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:21:45 +0800 Subject: [PATCH 17/34] =?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 18/34] =?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 19/34] =?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 c0e377f479c47d343f03f76037899f2b00f8b1ab Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 18:53:34 +0800 Subject: [PATCH 20/34] =?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 21/34] =?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 22/34] =?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 23/34] =?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 e0f9f811b7bbba0f781f580f7edc6d84c041026d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:34:17 +0800 Subject: [PATCH 24/34] =?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 6fcf42e4ac8c49762a3cc369ad526ea7d76d87b4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 19:44:48 +0800 Subject: [PATCH 25/34] =?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 26/34] =?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 efce7b102f7d546684a71ee4b7f0ca4f0bc89c9f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:01:26 +0800 Subject: [PATCH 27/34] =?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 da44d66dc87798b55c09ae83828a8ba46e83f2d4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:12:42 +0800 Subject: [PATCH 28/34] =?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 105591bac5aacd04cb171bed3200b48a3556af13 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 21:51:59 +0800 Subject: [PATCH 29/34] =?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}`}

+ ) : 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} - {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