diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index c9de909a6..b6e070a85 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage 3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. 4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. 5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. + - **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT` 与 `autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。 + - 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。 + - 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。 6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art. 7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. 8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md index 77bdf1f9c..5eeea8e90 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -7,5 +7,7 @@ - Score, steps, health, timer, or other core state updates consistently. - Restart restores all state and does not duplicate timers, animation loops, or event listeners. - Desktop and mobile layouts keep the core scene visible without accidental document scrolling. +- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。 +- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。 - HUD and overlays reserve space and do not cover essential interactive content. - Requested Taonier art is visibly integrated into the core experience when available. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6d4201d96..d1a51e9af 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.24", + "version": "2026-08-26.25", "skills": [ { "name": "agc-game-production-workflow", @@ -80,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" + "sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7bf8b2484..b33ebcf21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -797,6 +797,16 @@ fn direct_thread_visible_item( direct_thread_event_item(root, item) } +/// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。 +/// +/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + +/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; +/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 +fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { + let client_turn_id = client_turn_id.trim(); + (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -2927,7 +2937,7 @@ impl CodexAppServerConnection { None => direct_project_local_message_item( "user", current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), + direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(), ) .map_err(platform_llm::LlmError::InvalidRequest)?, }; @@ -3046,13 +3056,30 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let direct_thread_id = direct_thread_id_for_project(history_root); + // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 + // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 + // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 + // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); + // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 + // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 + let direct_turn_user_item_id = direct_persisted_user_item + .as_ref() + .and_then(direct_thread_item_identity); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_started(direct_turn_started_at_ms) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 + // 保留更早的真实发送时间,不用此事件时间覆盖它。 + let user_item_at = entry_item.at(); append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed(entry_item, user_item_at), ); } } @@ -3191,9 +3218,14 @@ impl CodexAppServerConnection { .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); if let Some(entry_item) = entry_item { + // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 + // 通知的钟就是该阶段唯一可证明的时间。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed( + entry_item, + direct_tool_call_now_ms(), + ), ); } } @@ -3322,9 +3354,19 @@ impl CodexAppServerConnection { if let Some(entry_item) = direct_thread_visible_item(history_root, item) { + // `item/started` 的通知层带 `startedAtMs`:这是工具真正 + // 开始的阶段时间,优先于条目展示时间与宿主钟。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_started(entry_item), + DirectThreadEvent::item_started( + entry_item, + direct_thread_item_event_at_ms( + ¶ms, + item, + false, + direct_tool_call_now_ms(), + ), + ), ); } } @@ -3367,9 +3409,19 @@ impl CodexAppServerConnection { && matches!(status, "completed" | "interrupted" | "failed") { terminal_recorded = true; + // 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生, + // 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::turn_completed(status.to_string()), + DirectThreadEvent::turn_completed( + status.to_string(), + direct_thread_turn_completed_at_ms( + turn, + Some(direct_turn_started_at_ms), + direct_tool_call_now_ms(), + ), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } match status { @@ -3426,7 +3478,11 @@ impl CodexAppServerConnection { "failed" } .to_string(), - ), + // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟, + // 不能拿最后一次正文或工具更新时间当回合终点。 + direct_tool_call_now_ms(), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } let text = match collect_result { @@ -3624,6 +3680,15 @@ enum DirectCodexTurnCancelTarget { /// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 /// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 /// 保持原拒绝语义,什么都不释放。 +/// +/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 +/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 +/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 +fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { + DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) + .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) +} + pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -3680,7 +3745,7 @@ pub(crate) fn cancel_direct_codex_turn_at( // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 append_direct_thread_event( &direct_thread_id_for_project(root), - DirectThreadEvent::turn_completed("aborted".to_string()), + direct_stale_cancel_turn_completed_event(&released), ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), @@ -5006,6 +5071,109 @@ mod tests { )); } + /// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema: + /// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒), + /// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。 + /// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住; + /// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。 + #[test] + fn direct_lifecycle_stage_times_come_from_notification_params() { + let started = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + let completed = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": 1_700_000_001_500u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + for (method, params, expected_at_ms) in [ + ("item/started", &started, 1_700_000_000_123u64), + ("item/completed", &completed, 1_700_000_001_500u64), + ] { + let Some(CodexTurnEvent::Item { + completed, + params: event_params, + }) = direct_codex_notification_event(method, params, None, None, "turn-1") + else { + panic!("{method} 必须分类成条目生命周期事件"); + }; + let item = event_params.get("item").expect("item payload"); + assert_eq!( + direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + expected_at_ms, + "{method} 必须用通知层的阶段时间,而不是宿主钟" + ); + } + + let terminal = serde_json::json!({ + "threadId": "thread-1", + "turn": { + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }, + }); + let Some(CodexTurnEvent::Terminal(params)) = + direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1") + else { + panic!("turn/completed 必须分类成终态事件"); + }; + let turn = params.get("turn").unwrap_or(¶ms); + assert_eq!( + direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + 9_999, + "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" + ); + let with_duration = serde_json::json!({ + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000, + "durationMs + 宿主高精度起点才派生结束" + ); + } + + /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId + /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 + #[test] + fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { + let event = direct_stale_cancel_turn_completed_event("turn-0001"); + assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); + assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); + assert!(matches!( + event, + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + )); + + for missing in ["", " "] { + let event = direct_stale_cancel_turn_completed_event(missing); + assert_eq!( + event.user_item_id(), + None, + "拿不到 clientTurnId 时不得编造开口条目身份" + ); + } + + // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 + assert_eq!( + direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), + Some("direct-codex:turn-0001:user") + ); + assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { custom_enabled: false, @@ -6740,12 +6908,33 @@ done let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) .expect("consume events"); + // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个 + // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。 + let lifecycle_user_item_ids = consumed + .events + .iter() + .filter(|event| { + matches!( + event, + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } + ) + }) + .map(DirectThreadEvent::user_item_id) + .collect::>(); + assert_eq!( + lifecycle_user_item_ids, + vec![ + Some("direct-codex:turn-0001:user"), + Some("direct-codex:turn-0001:user"), + ], + "turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId" + ); let mut user_items = Vec::new(); let mut assistant_items = Vec::new(); for event in &consumed.events { let item = match event { - DirectThreadEvent::ItemStarted { item } - | DirectThreadEvent::ItemCompleted { item } => item, + DirectThreadEvent::ItemStarted { item, .. } + | DirectThreadEvent::ItemCompleted { item, .. } => item, _ => continue, }; match item { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 91bc4ad1c..1c27b262f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -15,7 +15,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; @@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity( .to_string(), reference_resource_ids: asset.source.reference_resource_ids.clone(), }; + // 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有 + // 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。 + let references_match_contract = + crate::agent::platform_art_runtime_references_match_request_contract( + &identity.reference_resource_ids, + expected_kind, + ); let lineage_matches = match expected_reference_source { Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), - None => identity.reference_resource_ids.is_empty(), + None => true, }; - lineage_matches.then_some(identity) + (references_match_contract && lineage_matches).then_some(identity) }) } @@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source( source: &DirectTaonierArtAssetIdentity, derived: &DirectTaonierArtAssetIdentity, ) -> bool { - let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + // 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后, + // 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。 + let Some(remote_reference_id) = derived.reference_resource_ids.first() else { return false; }; if derived.canvas_project_id == source.canvas_project_id @@ -3390,6 +3399,8 @@ async fn generate_direct_taonier_art_asset_at( slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let runtime_context = @@ -5997,6 +6008,13 @@ mod tests { assert!( prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") ); + // Canvas 居中责任唯一的合同必须真的进到实际 system prompt:Phaser autoCenter 与 + // CSS 居中二选一,且要求实测中心误差与构建 dist 复验,避免再次出现居中偏移。 + assert!(prompt.contains("Phaser 画布居中责任唯一")); + assert!(prompt.contains("不得在同一个 canvas 父容器上叠加")); + assert!(prompt.contains("必须把 Phaser autoCenter 设为 NO_CENTER")); + assert!(prompt.contains("不得用修改 AGC iframe 偏移来掩盖")); + assert!(prompt.contains("中心误差不超过 1 CSS px")); } #[test] @@ -9803,6 +9821,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/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index a30816d82..ee3cfcd41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -427,12 +427,16 @@ mod tests { } } + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + fn item_started(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_started(message(item_id)) + DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) } fn item_completed(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_completed(message(item_id)) + DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) } fn item_delta(item_id: &str) -> DirectThreadEvent { @@ -450,7 +454,7 @@ mod tests { #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); manager.append("thread-1", item_started("item-1")); @@ -474,7 +478,7 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); manager.append("thread-1", item_started("item-1")); manager.append("thread-1", item_delta("item-1")); manager.append("thread-1", item_completed("item-1")); @@ -484,7 +488,7 @@ mod tests { assert!(matches!( bootstrap.events.as_slice(), [ - DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::TurnStarted { .. }, DirectThreadEvent::ItemStarted { item, .. }, ] if item.item_id() == "item-2" )); @@ -589,15 +593,118 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadEvent::turn_completed("completed".to_string()), + DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + [DirectThreadEvent::TurnCompleted { status, at, .. }] + if status == "completed" && *at == Some(FIXED_AT_MS) )); } + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + DirectThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first + .events + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + } + + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[test] + fn bootstrap_replays_opener_user_item_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!( + bootstrap + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")] + ); + assert_eq!(bootstrap.events[0].at(), Some(1_000)); + + // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 + manager + .consume(&bootstrap.subscription_id) + .expect("consume anchor"); + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 2_000), + ); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string(), 3_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let second = manager.subscribe("thread-1"); + assert_eq!( + second + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")], + "起止同源:终态锚点也带同一个开口用户条目身份" + ); + assert_eq!(second.events[0].at(), Some(3_000)); + } + #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index f6b781218..d5a874c76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -157,6 +157,23 @@ impl DirectThreadItem { | Self::Other { item_id, .. } => item_id, } } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } } /// 增量正文属于哪类条目。 @@ -201,18 +218,64 @@ impl DirectThreadRequestKind { /// /// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 /// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 +/// +/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical +/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 +/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 +/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] - TurnStarted, + TurnStarted { + /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, #[serde(rename = "turn.completed")] - TurnCompleted { status: String }, + TurnCompleted { + status: String, + /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, #[serde(rename = "item.started")] - ItemStarted { item: DirectThreadItem }, + ItemStarted { + item: DirectThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.completed")] - ItemCompleted { item: DirectThreadItem }, + ItemCompleted { + item: DirectThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.delta")] ItemDelta { item_id: String, @@ -228,20 +291,59 @@ pub(crate) enum DirectThreadEvent { } impl DirectThreadEvent { - pub(crate) fn turn_started() -> Self { - Self::TurnStarted + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } } - pub(crate) fn turn_completed(status: String) -> Self { - Self::TurnCompleted { status } + pub(crate) fn turn_completed(status: String, at: u64) -> Self { + Self::TurnCompleted { + status, + at: Some(at), + user_item_id: None, + } } - pub(crate) fn item_started(item: DirectThreadItem) -> Self { - Self::ItemStarted { item } + /// 附上本轮开口用户条目的 canonical itemId。 + /// + /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 + /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 + pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { + let user_item_id = user_item_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match self { + Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, + Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { + status, + at, + user_item_id, + }, + other => other, + } } - pub(crate) fn item_completed(item: DirectThreadItem) -> Self { - Self::ItemCompleted { item } + /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 + pub(crate) fn user_item_id(&self) -> Option<&str> { + match self { + Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { + user_item_id.as_deref() + } + _ => None, + } + } + + pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } + } + + pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } } pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { @@ -256,6 +358,19 @@ impl DirectThreadEvent { Self::Request { kind, request_id } } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at, .. } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + /// 事件关联的条目身份:只有 item 事件有。 pub(crate) fn item_id(&self) -> Option<&str> { match self { @@ -392,6 +507,63 @@ fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { observed_at_ms } +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn direct_thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn direct_thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + /// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { let call_id = item @@ -869,4 +1041,314 @@ mod tests { DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" )); } + + /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, + /// 后者只说明条目什么时候被看到。 + #[test] + fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); + } + + #[test] + fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); + } + + #[test] + fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); + } + + #[test] + fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); + } + + #[test] + fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); + } + + /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, + /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 + /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 + #[test] + fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); + } + + #[test] + fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); + } + + /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 + /// 反序列化仍成立,且不会序列化出 `at: null`。 + #[test] + fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + DirectThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = serde_json::to_value(DirectThreadEvent::turn_completed( + "completed".to_string(), + 2_000, + )) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = + serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!( + legacy, + DirectThreadEvent::TurnStarted { + at: None, + user_item_id: None, + } + ); + assert_eq!(legacy.at(), None); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(DirectThreadEvent::request( + DirectThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); + } + + /// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), + /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 + #[test] + fn lifecycle_events_carry_optional_opener_user_item_id() { + let started = DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&started).expect("serialize turn.started"), + json!({ + "type": "turn.started", + "at": 1_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&started).expect("serialize") + ) + .expect("round trip"), + started + ); + + let completed = DirectThreadEvent::turn_completed("interrupted".to_string(), 2_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&completed).expect("serialize turn.completed"), + json!({ + "type": "turn.completed", + "status": "interrupted", + "at": 2_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + // 起止同源:同一轮的两条边界带同一个身份。 + assert_eq!(started.user_item_id(), completed.user_item_id()); + + // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 + for empty in ["", " "] { + let event = DirectThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + assert_eq!(event.user_item_id(), None); + assert_eq!( + serde_json::to_value(&event).expect("serialize"), + json!({"type": "turn.started", "at": 1_000u64}) + ); + } + + // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "completed", + "at": 3_000u64, + })) + .expect("legacy turn.completed without userItemId"); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) + ); + + // 条目事件没有这个字段:身份只在生命周期事件上。 + let item_event = DirectThreadEvent::item_completed( + DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: None, + exit_code: None, + at: 1_500, + }, + 1_600, + ); + assert_eq!(item_event.user_item_id(), None); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 7eb8011df..c74690439 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 @@ -2391,6 +2391,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) slice_mode, grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, screen_color, }; let _generation_guard = state.image_generation_gate.lock().await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e52bbcfea..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,10 +67,11 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, + normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category, platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, - PLATFORM_ART_ASSET_GENERATION_KINDS, + platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 80e0dbc2a..a4327aff2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -420,6 +420,28 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) slice_mode: Option, pub(crate) grid_x: Option, pub(crate) grid_y: Option, + /// 本次生成用作参考的**当前项目已登记图片素材 id**(manifest `assets[].id`)。 + /// + /// 只接受当前项目 manifest 身份:路径、远端 `resourceId` / `objectKey` 与跨项目素材都会在 + /// [`resolve_platform_art_generation_references_at`] 解析阶段被拒绝,素材内容再经本地文件、 + /// 图片解码与内容 hash 换成**当前账号**绑定下的远端资源 ID。 + /// + /// 它**刻意不进** standalone 动作指纹([`StandalonePlatformArtGenerationFingerprintMaterial`]): + /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 + /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 + pub(crate) reference_asset_ids: Vec, + /// 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, /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 /// 仅 character 与 art-spritesheet 链路透传给服务端。 pub(crate) screen_color: Option, @@ -438,11 +460,45 @@ impl Default for PlatformArtAssetGenerationOptions { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: 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 张,总量仍不超过 +/// [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +pub(crate) const PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC: usize = 4; +/// 单个参考素材 id 的长度上限(manifest 资产 id 是稳定短标识,不是路径)。 +const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; + /// 「无源生成图片类素材」参数化通道放行的 kind 目录。 /// /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, @@ -477,6 +533,67 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option }) } +/// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 +/// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { + matches!( + asset_kind, + "ui-prototype" | "game-background" | "art-spritesheet" + ) +} + +/// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { + asset_kind != "art-spritesheet" +} + +/// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 +/// +/// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 +/// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 +pub(crate) fn normalize_platform_art_reference_asset_ids( + asset_kind: &str, + asset_ids: &[String], +) -> Result, String> { + if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { + return Err("透明美术图集只接受规范图引用,不接受用户参考素材".to_string()); + } + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + continue; + } + if asset_id.chars().count() > PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + || asset_id.chars().any(char::is_control) + || asset_id.contains('/') + || asset_id.contains('\\') + || asset_id.contains("://") + { + return Err(format!( + "参考素材只接受当前项目已登记素材 ID,不接受路径或远端资源 ID:{asset_id}" + )); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + } + } + if platform_art_asset_kind_requires_canonical_spec_reference(asset_kind) { + if normalized.len() > PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC { + return Err(format!( + "有规范图前置的生成最多 {} 张用户参考素材", + PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC + )); + } + } else if normalized.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "普通图片生成最多 {} 张参考素材", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(normalized) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -1608,12 +1725,62 @@ struct CanonicalArtSpecUploadTicket { form_fields: BTreeMap, } +/// 本次生成请求的全部参考资源身份。 +struct PlatformArtGenerationReferences { + /// 规范图前置引用:需要规范图的生成类型必定存在,其余类型为 `None`。 + canonical: Option, + /// 去重后的完整引用顺序:规范图在前,用户参考随后。它就是请求里的 `referenceImageSrcs`。 + ordered: Vec, +} + +/// 参考素材上传到平台时使用的文件名:由 manifest 素材的本地路径基名派生,只保留 ASCII 安全字符。 +/// +/// 同一素材路径不变时文件名稳定,平台对象键不会随重试漂移;无法派生时退回 +/// `reference.<媒体扩展名>`,扩展名由媒体类型决定,保持与 `contentType` 一致。 +fn platform_art_reference_upload_file_name(source: &GameCreationAppAssetManifestEntry) -> String { + let extension = infer_file_extension(Some(&source.local_path), &source.media_type); + let stem = Path::new(&source.local_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| { + stem.chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .collect::() + }) + .unwrap_or_default(); + if stem.is_empty() { + return format!("reference.{extension}"); + } + format!("{stem}.{extension}") +} + +/// 规范图前置的生成类型解析当前账号的规范图引用。 async fn canonical_art_spec_reference_at( root: &Path, client: &reqwest::Client, 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 @@ -1627,18 +1794,96 @@ async fn canonical_art_spec_reference_at( .ok_or_else(|| { "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() - })?; + })? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 用户参考素材(当前项目 manifest `assets[].id`)的清单归属解析。 +/// +/// 只接受**当前项目**清单里的素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 +fn manifest_asset_reference_entry_at( + root: &Path, + asset_id: &str, +) -> 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}"))? + .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, source.id + )); + } + 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( - "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" - .to_string(), - ); + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); } - let bytes = - fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + 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」的唯一通道。 +/// +/// 复用既有 manifest → 安全文件路径 → 图片解码 → 内容 hash → 当前账号 binding/上传 → +/// 远端 resource ID 流程:binding 存在时直接复用,缺失时从本地正式文件重新上传并登记, +/// 绝不用当前 token 探测或发送历史账号的 project/resource ID。 +async fn upload_manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + manifest_project_id: &str, + expected_canvas_project_id: &str, + source: &GameCreationAppAssetManifestEntry, +) -> Result { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` 门禁, + // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 + enforce_project_permission_policy(root, "asset.upload")?; + let file_name = platform_art_reference_upload_file_name(source); + // 与提交前预检共用同一份本地判据:受控路径、文件存在、媒体类型(含 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, @@ -1648,19 +1893,19 @@ async fn canonical_art_spec_reference_at( )?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } let principal_key = - external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + external_editor_project_binding_key_sha256(manifest_project_id, &principal)?; let resource_binding_key = external_editor_resource_binding_key_sha256( - &manifest.project_id, + manifest_project_id, &principal_key, expected_canvas_project_id, &source_identity, @@ -1671,14 +1916,14 @@ async fn canonical_art_spec_reference_at( access.validate_frozen_session()?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 @@ -1701,57 +1946,57 @@ async fn canonical_art_spec_reference_at( "pathSegments": [ "editor", "account-scoped-bindings", - manifest.project_id.as_str(), + manifest_project_id, source_identity.source_sha256.as_str() ], - "fileName": "art-spec.png", + "fileName": file_name.as_str(), "contentType": source.media_type, "access": "private", "maxSizeBytes": bytes.len(), "successActionStatus": 204, })), - "创建当前账号规范图上传凭证", + "创建当前账号参考图上传凭证", ) .await?; access.validate_frozen_session()?; let upload = external_editor_response_data(&ticket_payload) .get("upload") .or_else(|| ticket_payload.pointer("/data/upload")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + .ok_or_else(|| "当前账号参考图上传凭证缺少 upload".to_string())?; let ticket = CanonicalArtSpecUploadTicket { host: json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 host".to_string())?, bucket: json_string_field(upload, "bucket") - .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 bucket".to_string())?, object_key: json_string_field(upload, "objectKey") - .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 objectKey".to_string())?, success_action_status: upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证 successActionStatus 无效".to_string())?, form_fields: upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .ok_or_else(|| "当前账号参考图上传凭证缺少 formFields".to_string())? .iter() .map(|(key, value)| { value .as_str() .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + .ok_or_else(|| "当前账号参考图上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?, }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1760,18 +2005,20 @@ async fn canonical_art_spec_reference_at( form.text(key.clone(), value.clone()) }); let part = Part::bytes(bytes.clone()) - .file_name("art-spec.png") + // `Part::file_name` 只接受 `'static` 名字:这里必须交出所有权,借用会让临时串在 + // 请求发出前就结束生命周期。 + .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号规范图失败".to_string())?; + .map_err(|_| "上传当前账号参考图失败".to_string())?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( - "上传当前账号规范图失败:HTTP {}", + "上传当前账号参考图失败:HTTP {}", upload_response.status().as_u16() )); } @@ -1793,19 +2040,19 @@ async fn canonical_art_spec_reference_at( "assetKind": source.kind, "accessPolicy": "private", })), - "确认当前账号规范图上传", + "确认当前账号参考图上传", ) .await?; access.validate_frozen_session()?; let asset_object = external_editor_response_data(&confirm_payload) .get("assetObject") .or_else(|| confirm_payload.pointer("/data/assetObject")) - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObject".to_string())?; if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + return Err("当前账号参考图确认响应 objectKey 不一致".to_string()); } let asset_object_id = json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObjectId".to_string())?; let resource_payload = external_editor_json_request( client .post(format!( @@ -1834,7 +2081,7 @@ async fn canonical_art_spec_reference_at( "localAssetId": source.id, }, })), - "登记当前账号规范图项目资源", + "登记当前账号参考图项目资源", ) .await?; let post_response_session = access.validate_frozen_session(); @@ -1843,9 +2090,9 @@ async fn canonical_art_spec_reference_at( resource_data.get("resource").unwrap_or(resource_data), "resourceId", ) - .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + .ok_or_else(|| "当前账号参考图项目资源响应缺少 resourceId".to_string())?; let binding = new_external_editor_resource_binding( - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, @@ -1861,6 +2108,83 @@ async fn canonical_art_spec_reference_at( Ok(remote_resource_id) } +/// 解析本次生成请求的全部参考资源身份。 +/// +/// 顺序与上限是请求合同的一部分: +/// +/// - 规范图前置的生成类型必须先解析出当前账号的规范图引用; +/// - 用户参考来自 `options.reference_asset_ids`(当前项目 manifest `assets[].id`),按给出顺序 +/// 逐个换成当前账号的远端资源 ID,并按远端 ID 去重; +/// - 图集类型只接受单规范引用,用户参考在这里被**拒绝**而不是静默丢弃; +/// - 合并后总数不超过 [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +async fn resolve_platform_art_generation_references_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( + &options.asset_kind, + &options.reference_asset_ids, + )?; + let 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 (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, + source, + ) + .await?; + if !ordered.iter().any(|existing| existing == &reference) { + ordered.push(reference); + } + } + if ordered.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "图片生成参考素材最多 {} 张(含规范图)", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(PlatformArtGenerationReferences { canonical, ordered }) +} + fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // External Editor validates each description independently (currently at // 200 Unicode characters). Keep the gameplay context short enough that a @@ -2383,6 +2707,44 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ .await } +/// 保留账本里的参考集合是否符合该生成类型的**请求合同**。 +/// +/// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 +/// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: +/// +/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 +/// +/// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 +/// [`resolve_platform_art_generation_references_at`],这里只回答「已有账本/清单里的这份参考集合, +/// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 +pub(crate) fn platform_art_runtime_references_match_request_contract( + reference_resource_ids: &[String], + expected_asset_kind: &str, +) -> bool { + if reference_resource_ids + .iter() + .any(|reference| reference.trim().is_empty()) + { + return false; + } + // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 + if expected_asset_kind != "game-background" + && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + { + return false; + } + if !platform_art_asset_kind_accepts_user_reference_assets(expected_asset_kind) { + return reference_resource_ids.len() == 1; + } + if platform_art_asset_kind_requires_canonical_spec_reference(expected_asset_kind) { + return (1..=PLATFORM_ART_MAX_REFERENCE_IMAGES).contains(&reference_resource_ids.len()); + } + reference_resource_ids.len() <= PLATFORM_ART_MAX_REFERENCE_IMAGES +} + pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, @@ -2403,24 +2765,35 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { + // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: + // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 "icon-spec" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.is_empty() + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "icon-spec", + ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } "game-background" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "game-background", + ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } "art-spritesheet" => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "art-spritesheet", + ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") } @@ -2685,23 +3058,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); } - if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - let current_reference = canonical_art_spec_reference_at( + { + // 恢复的判据是「本次请求解析出的当前账号引用」与账本快照逐一相符:规范图身份漂移 + // 与用户参考变化都必须被识别,不能把上一次请求的参考当成本次请求的参考恢复。 + let current_references = resolve_platform_art_generation_references_at( root, &client, &binding_access, &snapshot.canvas_project_id, + options, ) .await .map_err(|error| { format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化生成请求的当前规范图与参考素材身份:{error}" ) })?; - if snapshot.reference_resource_ids != [current_reference] { + if snapshot.reference_resource_ids != current_references.ordered { if platform_art_generation_runtime_status(&state) == "accepted" { if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) @@ -2733,7 +3106,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } } return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份或用户参考素材与已持久化请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } @@ -2815,22 +3188,17 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - Some( - canonical_art_spec_reference_at( - root, - &client, - &binding_access, - &canvas_context.project_id, - ) - .await?, - ) - } else { - None - }; + // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, + // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 + let references = resolve_platform_art_generation_references_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + options, + ) + .await?; + let canonical_reference = references.canonical.clone(); let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -2874,7 +3242,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "generationInputs": { "artSpec": platform_art_asset_art_spec(options), }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "referenceImageSrcs": references.ordered.clone(), "canvasCompletion": { "title": options.asset_label, "placeholder": external_canvas_placeholder(&options.aspect_ratio), @@ -3061,7 +3429,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at endpoint.to_string(), generation_kind.to_string(), is_canonical_art_spritesheet, - canonical_reference.into_iter().collect::>(), + references.ordered.clone(), generation_prompt.clone(), ) }; @@ -7713,7 +8081,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, @@ -7731,6 +8099,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) => { @@ -8448,6 +8818,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let ordinary = @@ -10499,6 +10871,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "生成同一套整包美术"; @@ -11404,6 +11778,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "保持同一个生成提示词"; @@ -11869,6 +12245,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "恢复已受理视觉规范图"; @@ -12467,6 +12845,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()), @@ -12479,6 +13175,8 @@ mod canvas_generation_tests { slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: 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 5c1e7ffda..a61e1df4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -577,6 +577,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; if let Some(pending) = pending_action { @@ -629,6 +631,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), grid_x, grid_y, + reference_asset_ids: requested_options.reference_asset_ids, + // Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。 + target_category: requested_options.target_category, screen_color: requested_options.screen_color, } }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 91a9eae42..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 @@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation( image_size: Option, asset_name: Option, output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + 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 fb6edc2c4..79d0dc2a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2375,6 +2375,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, @@ -4926,6 +4947,8 @@ pub(crate) fn prepare_local_project_asset_generation( image_size: Option<&str>, 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() { @@ -4933,6 +4956,13 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, + // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 + let reference_asset_ids = + normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?; + // 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)?, @@ -4969,6 +4999,8 @@ pub(crate) fn prepare_local_project_asset_generation( .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids, + target_category, screen_color: None, }, }) @@ -4991,6 +5023,10 @@ pub(crate) async fn generate_local_project_asset( image_size: Option, 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, @@ -5000,6 +5036,8 @@ pub(crate) async fn generate_local_project_asset( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -5018,7 +5056,17 @@ mod local_project_asset_generation_tests { use super::*; fn prepare(kind: &str, prompt: &str) -> Result { - prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None) + prepare_local_project_asset_generation( + "/tmp/project", + kind, + prompt, + None, + None, + None, + None, + &[], + None, + ) } #[test] @@ -5058,6 +5106,8 @@ mod local_project_asset_generation_tests { Some("2K"), Some(" 主角图集 "), Some(" assets/hero.png "), + &[], + None, ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -5085,8 +5135,18 @@ mod local_project_asset_generation_tests { #[test] fn invalid_toolbar_arguments_are_rejected_before_any_generation() { assert_eq!( - prepare_local_project_asset_generation("", "image", "要求", None, None, None, None) - .expect_err("empty project path"), + prepare_local_project_asset_generation( + "", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect_err("empty project path"), "项目路径不能为空" ); assert_eq!( @@ -5097,6 +5157,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", @@ -5113,7 +5227,9 @@ mod local_project_asset_generation_tests { Some("4:3"), None, None, - None + None, + &[], + None, ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -5126,7 +5242,9 @@ mod local_project_asset_generation_tests { None, Some("4K"), None, - None + None, + &[], + None, ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -5139,7 +5257,9 @@ mod local_project_asset_generation_tests { None, None, Some("坏\u{7}名字"), - None + None, + &[], + None, ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -5152,7 +5272,9 @@ mod local_project_asset_generation_tests { None, None, None, - Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), + &[], + None, ) .expect_err("oversized output path"), "输出路径超出安全边界" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 973197ae6..811456b96 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 a3fe7968f..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 @@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset( } if task_id == "art-director" { - if !asset.source.reference_resource_ids.is_empty() { - return Err("统一视觉规范图不得声明派生资源引用".to_string()); + // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 + // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 + // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / + // resourceId / PNG 解码等身份判据全部保持不变。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { + return Err(format!( + "统一视觉规范图的参考集合不符合请求合同:{expected_path}" + )); } return Ok(()); } @@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { + // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: + // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); - }; + } + let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); let original_provenance_matches = canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; let rebound_local_source_matches = if original_provenance_matches { @@ -1328,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-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1dcdad4e3..5aedeadd0 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]) @@ -1034,6 +1106,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"); @@ -1094,6 +1173,8 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }, ) @@ -1102,9 +1183,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)] @@ -2730,6 +3187,8 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("2K".to_string()), Some("工具栏图片".to_string()), None, + None, + None, ) .await .expect("toolbar asset generation"); @@ -2762,6 +3221,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(); @@ -2796,6 +3348,8 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s None, Some("视觉规范图".to_string()), None, + None, + None, ) .await .expect("toolbar spec generation"); @@ -2862,6 +3416,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -2885,6 +3441,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("1K".to_string()), Some("游戏首版图集".to_string()), None, + None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -5632,6 +6190,8 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = build_platform_art_asset_prompt( 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 279b3e743..8fef84893 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 @@ -6927,6 +6927,22 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "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/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f49e8b792..16fb765b0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -241,7 +241,9 @@ import { readDirectHistoryPages } from './features/project-workspace/directHisto import { applyDirectThreadConsumeResult, type DirectThreadChatState, + directThreadTurnMatchesUser, emptyDirectThreadChatState, + finishDirectThreadTurn, mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, @@ -12188,8 +12190,19 @@ export function App({ const message = result?.message?.trim(); if (result?.outcome === 'released') { // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 - // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 - setDirectThreadChat((state) => ({ ...state, turnRunning: false })); + // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 + // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, + // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), + // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 + const cancelledUserItemId = result.clientTurnId + ? directCodexConversationMessageId(result.clientTurnId, 'user') + : ''; + setDirectThreadChat((state) => + directThreadTurnMatchesUser(state, cancelledUserItemId) + ? finishDirectThreadTurn(state, Date.now()) + : state, + ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12399,6 +12412,7 @@ export function App({ chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} directTurnRunning={directCodexProductRuntime && directTurnRunning} + directTurnStartedAt={directThreadChat.turnStartedAt} directEntries={ directCodexProductRuntime ? selectDirectChatEntries(directThreadChat) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 3018200fb..1273bb017 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration'; import { resolveTauriInvoke } from '../../app/tauri'; import type { PlanGddDecisionAction, @@ -171,7 +172,7 @@ export function PlanGddStageProgress({ : `当前版本:v${latestVersion}`} {processingSeconds > 0 ? ( - {`处理耗时:${processingSeconds.toFixed(1)} 秒`} + {`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`} ) : null} {deliveredGdd ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index dca93b24b..4a21a7550 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -1,4 +1,12 @@ -import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; +import { + ArrowUp, + AtSign, + ChevronDown, + Lightbulb, + Loader2, + Settings, + Wrench, +} from 'lucide-react'; import type { ComponentProps, FormEventHandler, @@ -12,6 +20,7 @@ import { AgentMessageContent, type AgentMessageTone, } from '../../../../../packages/shared/src/components/AgentMessageContent'; +import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary'; import type { AgentStatusCard, ChatMessage, @@ -41,6 +50,7 @@ import { import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { agentProcessPreview } from './agentProcessPreview'; import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerPendingAttachments, @@ -81,8 +91,29 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; import { formatClockTime, - formatTurnDuration, + resolveToolGroupTiming, + resolveTurnTiming, } from './toolCallGroupPresentation'; +import { useLiveNow } from './useLiveNow'; + +/** + * 运行中整轮总耗时:自带 100ms 时钟的小叶子。 + * + * 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。 + * 起点拿不到时不渲染:不编造不能证明的耗时。 + */ +function TurnElapsedTotal({ startedAt }: { startedAt: number }) { + const now = useLiveNow(startedAt > 0); + const timing = resolveTurnTiming({ startedAt, running: true, now }); + if (!timing.durationText) { + return null; + } + return ( + + {`总耗时 ${timing.durationText}`} + + ); +} /** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ @@ -94,6 +125,9 @@ function AgentReasoning({ label?: string; testId?: string; }) { + const [expanded, setExpanded] = useState(false); + // 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。 + const preview = agentProcessPreview(text); return ( + setExpanded((event.currentTarget as HTMLDetailsElement).open) + } > - 思考过程 -
{text}
+ + + {/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */} +
); } @@ -125,6 +169,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { directEntries?: DirectChatEntry[]; /** 最新回合是否还在跑;只由生命周期事件决定。 */ directTurnRunning?: boolean; + /** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */ + directTurnStartedAt?: number; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -195,6 +241,7 @@ export function ProjectSupervisorView({ directCodex = false, directEntries = [], directTurnRunning = false, + directTurnStartedAt = 0, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -246,8 +293,6 @@ export function ProjectSupervisorView({ const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 const [voiceNotice, setVoiceNotice] = useState(''); const [approvalOpen, setApprovalOpen] = useState(false); @@ -267,41 +312,65 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - useEffect(() => { - if (!directTurnRunning) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [directTurnRunning]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex ? buildDirectChatTurns({ entries: directEntries, localMessages: conversationMessages, turnRunning: directTurnRunning, + turnStartedAt: directTurnStartedAt, }) : []; const activeTurnStartedAt = directTurns.find((turn) => turn.active)?.startedAt ?? 0; + const latestDirectTurn = directTurns.at(-1); + // 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。 + const awaitingFirstResponse = + directCodex && + (runBusy || directTurnRunning) && + !turnCancelling && + !runtimePanelProps.error && + Boolean(latestDirectTurn?.users.length) && + latestDirectTurn?.process.length === 0 && + latestDirectTurn?.finals.length === 0; + // 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染: + // - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位; + // - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。 + // 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。 + const initialSupervisorText = initialSupervisorMessage.trim(); + const initialSupervisorPlaceholderSuperseded = + hasEarlierConversationMessages || + (directCodex + ? directEntries.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) || conversationMessages.some((message) => message.role === 'user') + : conversationMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorText, + )); - const clockTimeWithSeconds = (timestamp: number) => { - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - }; - + /** + * 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染—— + * 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。 + */ const renderTurnUsage = (turn: DirectChatTurn) => { - if (turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); + if (turn.active) return null; + const timing = resolveTurnTiming({ + startedAt: turn.startedAt, + endedAt: turn.endedAt, + }); + if (!timing.durationText) return null; + const endedLabel = formatClockTime(turn.endedAt, { tenths: true }); return (

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

); }; @@ -435,12 +504,7 @@ export function ProjectSupervisorView({ : '显示更早的对话'} ) : null} - {initialSupervisorMessage.trim() && - !conversationMessages.some( - (message) => - message.role === 'user' && - message.text.trim() === initialSupervisorMessage.trim(), - ) ? ( + {initialSupervisorText && !initialSupervisorPlaceholderSuperseded ? (
block.kind === 'assistant')?.key ?? null) : null; + // 外层"执行过程"汇总**这一轮全部工具调用**(不是过程段落数,也不含思考段): + // 计数 = 所有组的 calls 之和;耗时 = 首工具开始 → 末工具完成(各组的并集跨度, + // 不是整轮总耗时,也不与整轮那处重复);边界不完整时隐藏。 + const turnToolCalls = turn.process.flatMap((block) => + block.kind === 'tools' ? block.calls : [], + ); + const turnToolSpan = resolveToolGroupTiming(turnToolCalls, { + running: false, + }); const renderBlock = ( block: DirectChatBlock, tone: AgentMessageTone = 'body', @@ -465,7 +538,6 @@ export function ProjectSupervisorView({ @@ -524,7 +596,24 @@ export function ProjectSupervisorView({ className="message-turn-process" data-testid="turn-process" > - 执行过程 + +
{turn.process.map((block) => renderBlock(block, 'process'), @@ -573,6 +662,18 @@ export function ProjectSupervisorView({
) : null} + {awaitingFirstResponse ? ( + + + ) : null}
{directCodex ? null : planningSurfaceActive ? (