From cd8f480719558791a346c08acca44284d9e9507f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 18 Sep 2026 13:25:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E9=9A=94=E7=A6=BB=E4=B8=8E=E5=9B=9E=E5=90=88=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E6=81=A2=E5=A4=8D=E5=8F=8A=E5=B8=83=E5=B1=80=E6=8E=92?= =?UTF-8?q?=E9=98=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按占位身份隔离生成面板并保存草稿,阻止改变失败请求输入后伪装重试。 保留用户真实发送时间,按既有用户条目身份恢复空运行态回合的终态边界。 修复跨栏目整理队列覆盖及多选拖动预览起点漂移。 补齐六项 review 发现的回归、原生绑定、交叉审查及浏览器验收记录。 --- .../src/agent/codex_app_server/mod.rs | 91 ++- .../src/agent/direct_thread_manager.rs | 49 +- .../src-tauri/src/agent/direct_thread_wire.rs | 139 +++- apps/ai-game-creator-shell/src/App.tsx | 10 +- .../project-workspace/directThreadChat.ts | 147 +++- .../directTurnPresentation.ts | 54 +- .../generated/DirectThreadEvent.ts | 14 + ...ResourceCanvasAssetGenerationPanelView.tsx | 179 ++++- .../ResourceCanvasGenerationPanelView.tsx | 117 +++- ...urceCanvasAssetGenerationReferenceModel.ts | 156 ++++- .../resourceCanvasAssetGenerationTaskModel.ts | 10 + .../src/view/project-development/index.tsx | 129 +++- .../useProjectResourceCanvasLayout.ts | 80 ++- .../tests/directThreadChat.test.ts | 271 +++++++- .../tests/directTurnPresentation.test.ts | 24 + ...urceCanvasGenerationHostLifecycle.test.tsx | 625 ++++++++++++++---- .../resourceCanvasGenerationLanding.test.tsx | 34 +- .../tests/resourceCanvasManualLayout.test.tsx | 109 ++- .../useProjectResourceCanvasLayout.test.ts | 404 ++++++++++- ...计划】对话总耗时与动态工具计时-2026-09-18.md | 12 + ...施计划】画布验收问题统一修复-2026-09-17.md | 8 + ...程碑】对话总耗时与动态工具计时-2026-09-18.md | 2 + docs/project-memory/shared-memory/pitfalls.md | 6 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 2 + 25 files changed, 2412 insertions(+), 266 deletions(-) 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 f35701eac..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)?, }; @@ -3051,15 +3061,21 @@ impl CodexAppServerConnection { // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 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(direct_turn_started_at_ms), + 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) { - // 用户消息由 AGC 自己落盘,条目时间就是真实发送时间:事件级 `at` 直接 - // 复用这一条条目的时间,不另取宿主钟。 + // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 + // 保留更早的真实发送时间,不用此事件时间覆盖它。 let user_item_at = entry_item.at(); append_direct_thread_event( &direct_thread_id, @@ -3404,7 +3420,8 @@ impl CodexAppServerConnection { Some(direct_turn_started_at_ms), direct_tool_call_now_ms(), ), - ), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } match status { @@ -3464,7 +3481,8 @@ impl CodexAppServerConnection { // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟, // 不能拿最后一次正文或工具更新时间当回合终点。 direct_tool_call_now_ms(), - ), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } let text = match collect_result { @@ -3662,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>, @@ -3718,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_tool_call_now_ms()), + direct_stale_cancel_turn_completed_event(&released), ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), @@ -5118,6 +5145,35 @@ mod tests { ); } + /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源: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, @@ -6852,6 +6908,27 @@ 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 { 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 5140f473c..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 @@ -598,7 +598,7 @@ mod tests { let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status, at }] + [DirectThreadEvent::TurnCompleted { status, at, .. }] if status == "completed" && *at == Some(FIXED_AT_MS) )); } @@ -658,6 +658,53 @@ mod tests { ); } + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[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 e2f70a267..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 @@ -227,6 +227,12 @@ impl DirectThreadRequestKind { /// 在该阶段取的毫秒钟——见 `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/"))] @@ -237,6 +243,10 @@ pub(crate) enum DirectThreadEvent { #[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 { @@ -245,6 +255,10 @@ pub(crate) enum DirectThreadEvent { #[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 { @@ -278,13 +292,49 @@ pub(crate) enum DirectThreadEvent { impl DirectThreadEvent { pub(crate) fn turn_started(at: u64) -> Self { - Self::TurnStarted { at: Some(at) } + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } } pub(crate) fn turn_completed(status: String, at: u64) -> Self { Self::TurnCompleted { status, at: Some(at), + user_item_id: None, + } + } + + /// 附上本轮开口用户条目的 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, + } + } + + /// 本轮开口用户条目的 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, } } @@ -313,7 +363,7 @@ impl DirectThreadEvent { /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 pub(crate) fn at(&self) -> Option { match self { - Self::TurnStarted { at } + Self::TurnStarted { at, .. } | Self::TurnCompleted { at, .. } | Self::ItemStarted { at, .. } | Self::ItemCompleted { at, .. } => *at, @@ -1203,8 +1253,15 @@ mod tests { // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) .expect("legacy turn.started without at"); - assert_eq!(legacy, DirectThreadEvent::TurnStarted { at: None }); + assert_eq!( + legacy, + 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"}) @@ -1218,4 +1275,80 @@ mod tests { 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/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c9e0a01a2..2956446ef 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -241,6 +241,7 @@ import { readDirectHistoryPages } from './features/project-workspace/directHisto import { applyDirectThreadConsumeResult, type DirectThreadChatState, + directThreadTurnMatchesUser, emptyDirectThreadChatState, finishDirectThreadTurn, mergeDirectHistoryItems, @@ -12189,8 +12190,15 @@ export function App({ // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), + // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 + const cancelledUserItemId = result.clientTurnId + ? directCodexConversationMessageId(result.clientTurnId, 'user') + : ''; setDirectThreadChat((state) => - finishDirectThreadTurn(state, Date.now()), + directThreadTurnMatchesUser(state, cancelledUserItemId) + ? finishDirectThreadTurn(state, Date.now()) + : state, ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index aec57e18d..5838bfb9c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -2,8 +2,9 @@ * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + - * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject - * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 + * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, + * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 + * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 */ import type { GameCreatorDirectToolCall } from '../../app/types'; @@ -52,6 +53,13 @@ export type DirectThreadChatState = { turnStartedAt: number; /** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */ turnEndedAt: number; + /** + * 本轮 canonical user identity(原生生命周期事件带的 `userItemId`,即本轮用户条目身份)。 + * + * 收口时按它精确回填本轮开口条目的展示边界:身份来自原生事件,不是第二套回合状态源, + * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 + */ + turnUserItemId: string; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -63,6 +71,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnRunning: false, turnStartedAt: 0, turnEndedAt: 0, + turnUserItemId: '', history: [], live: [], }; @@ -86,6 +95,19 @@ export function readDirectThreadEventAt(event: DirectThreadEvent): number { return validBoundaryAt('at' in event ? event.at : 0); } +/** + * 生命周期事件自带的 canonical user identity(`userItemId`),即本轮用户条目的稳定 itemId。 + * + * 字段可选:缺失表示身份不可证明(旧事件保持原有顺序语义),此时不猜历史归属,也不拿 + * 时间戳近似。 + */ +export function readDirectThreadEventUserItemId( + event: DirectThreadEvent, +): string { + const value = 'userItemId' in event ? event.userItemId : ''; + return typeof value === 'string' ? value.trim() : ''; +} + function longerText( left: string | null | undefined, right: string | null | undefined, @@ -200,6 +222,47 @@ function upsertLiveEntry( return { ...state, live }; } +/** + * 只补条目上缺失的展示边界:已经写上的起点 / 终点不会被后来的收口改写(冻结先到值)。 + * + * 收口会被调用多次(重复的终态事件、宿主终止收口 + 原生补发的终态),因此这里必须是 + * 单向填空而不是 `{...entry, ...boundary}` 覆盖——否则迟到 / 重放的那一次会把已经冻结的 + * 终点抬高,界面上的总耗时在收口之后又变一次。 + */ +function withTurnBoundary( + entry: DirectChatEntry, + boundary: Pick, +): DirectChatEntry { + const turnStartedAt = entry.turnStartedAt || boundary.turnStartedAt; + const turnEndedAt = entry.turnEndedAt || boundary.turnEndedAt; + return { + ...entry, + ...(turnStartedAt ? { turnStartedAt } : {}), + ...(turnEndedAt ? { turnEndedAt } : {}), + }; +} + +/** + * 按 canonical user identity 给本轮开口条目补展示边界:只有 `itemId` 正好是本轮 + * `userItemId` 的那一条(可能已经在历史里,也可能收口之后才回读到)才拿边界。 + * + * 没有身份(旧事件)时原样返回:不猜历史归属,旧历史继续隐藏未知用时。 + */ +function stampTurnUserEntry( + entries: DirectChatEntry[], + userItemId: string, + boundary: Pick, +): DirectChatEntry[] { + if (!userItemId || Object.keys(boundary).length === 0) return entries; + let matched = false; + const next = entries.map((entry) => { + if (entry.itemId !== userItemId) return entry; + matched = true; + return withTurnBoundary(entry, boundary); + }); + return matched ? next : entries; +} + function appendLiveText( state: DirectThreadChatState, event: Extract, @@ -231,15 +294,29 @@ export function reduceDirectThreadEvent( state.turnRunning && state.turnStartedAt > 0 ? state.turnStartedAt : eventAt; + // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 + // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 + const turnUserItemId = readDirectThreadEventUserItemId(event); return { ...state, turnRunning: true, turnStartedAt, turnEndedAt: 0, + turnUserItemId, }; } case 'turn.completed': { const eventAt = readDirectThreadEventAt(event); + const eventUserItemId = readDirectThreadEventUserItemId(event); + // 带身份的终态只收本轮的:上一轮迟到 / 重放的终态按 id 拒绝,绝不关掉正在跑的这一轮。 + // 判据是身份,不是时间戳大小(同一秒内的新回合也必须能正常收口)。 + if ( + eventUserItemId && + state.turnUserItemId && + eventUserItemId !== state.turnUserItemId + ) { + return state; + } // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 if (!state.turnRunning && state.live.length === 0) { return state; @@ -303,18 +380,14 @@ export function finishDirectThreadTurn( ...(turnStartedAt > 0 ? { turnStartedAt } : {}), ...(turnEndedAt > 0 ? { turnEndedAt } : {}), }; - const stamped = state.live.map((entry) => ({ ...entry, ...boundary })); - // 本轮的开口条目是 live 里那条用户消息;bootstrap 可能已经把用户消息当历史锚点发过, - // 这时 live 里只有过程条目。同一份边界只补到"历史最后一条、且是用户条目"上:那种位置 - // 只可能是本轮的开口条目(后面还没有任何内容),不会命中上一轮已经写完正文的开口条目。 - const history = - state.live.length > 0 && - !state.live.some( - (entry) => entry.kind === 'message' && entry.role === 'user', - ) && - Object.keys(boundary).length > 0 - ? stampTrailingTurnOpener(state.history, boundary) - : state.history; + const stamped = state.live.map((entry) => withTurnBoundary(entry, boundary)); + // 本轮开口条目按 canonical user identity 精确回填:它可能已经在历史里(历史切片先到), + // 也可能这一轮压根没有运行态条目。命中不了就不盖——不按历史尾或用时间戳猜归属。 + const history = stampTurnUserEntry( + state.history, + state.turnUserItemId, + boundary, + ); return { ...state, turnRunning: false, @@ -325,18 +398,20 @@ export function finishDirectThreadTurn( }; } -/** 历史最后一条正是本轮的开口条目时,补上同一份边界;否则原样返回。 */ -function stampTrailingTurnOpener( - history: DirectChatEntry[], - boundary: Pick, -): DirectChatEntry[] { - const last = history[history.length - 1]; - if (!last || last.kind !== 'message' || last.role !== 'user') { - return history; - } - const next = [...history]; - next[next.length - 1] = { ...last, ...boundary }; - return next; +/** + * 取消 / 收口回包是否还对应界面上的这一轮。 + * + * 按 canonical user identity 判(不是时间戳):调用方先核对回包里的 clientTurnId 对应的 + * 用户条目就是当前这一轮,再本地收口;否则(新回合已经开始)不要在新回合的回调里收口旧轮。 + * 任一侧没有身份(旧原生)时返回真,保持原有顺序语义。 + */ +export function directThreadTurnMatchesUser( + state: DirectThreadChatState, + userItemId: string, +): boolean { + const expected = userItemId.trim(); + if (!expected || !state.turnUserItemId) return true; + return state.turnUserItemId === expected; } export function reduceDirectThreadEvents( @@ -405,12 +480,24 @@ export function mergeDirectHistoryItems( state: DirectThreadChatState, items: readonly DirectThreadItem[], ): DirectThreadChatState { + const history = mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ); + // 终态可能先于历史切片到达:收口之后才回读到本轮开口条目时,按同一个 canonical + // user identity 把已经冻结的边界补上(只填空;旧历史没有身份或身份不匹配就不盖)。 + const stamped = + state.turnEndedAt > 0 + ? stampTurnUserEntry(history, state.turnUserItemId, { + ...(state.turnStartedAt > 0 + ? { turnStartedAt: state.turnStartedAt } + : {}), + turnEndedAt: state.turnEndedAt, + }) + : history; return { ...state, - history: mergeHistoryEntries( - projectDirectHistoryItems(items), - state.history, - ), + history: stamped, }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index 503a2d3c3..4034ad94b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -66,6 +66,7 @@ type DirectChatTurnEntries = { function blockFromEntry( entry: DirectChatEntry, key: string, + localSentAt: ReadonlyMap, ): DirectChatBlock | null { if (entry.kind === 'tool') { return entry.toolCall @@ -78,7 +79,16 @@ function blockFromEntry( return { kind: 'reasoning', key, text }; } return entry.role === 'user' - ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } + ? { + kind: 'user', + key, + text, + at: sameIdentitySentAt( + entry.itemId, + normalizeDirectTimestamp(entry.at), + localSentAt, + ), + } : { kind: 'assistant', key, @@ -87,6 +97,41 @@ function blockFromEntry( }; } +/** + * 同身份(同一个 itemId)的本地乐观发送时间,按 messageId 建立索引。 + * + * DirectProject 运行期只在 `messages` 里保留本地乐观消息,正式条目走 `directEntries`: + * 两者身份相同(`direct-codex:{turnId}:user`),所以这里能按身份把用户真正按下发送的时刻 + * 找回来,不需要、也不允许按整轮所有条目取最小值猜起点。 + */ +function localSentTimes(messages: readonly ChatMessage[]): Map { + const sentAt = new Map(); + for (const message of messages) { + if (message.role !== 'user' || !message.messageId) continue; + const at = normalizeDirectTimestamp(message.updatedAt); + if (at <= 0) continue; + const known = sentAt.get(message.messageId); + if (known === undefined || at < known) sentAt.set(message.messageId, at); + } + return sentAt; +} + +/** + * 同身份合并后的用户发送时间:正式条目的 `at` 是宿主观测到的 ack 时间,晚于真实发送时刻。 + * 因此同一 itemId 上取两个时刻里更早的那个,迟到的 ack 顶不掉真实发送时间; + * 找不到同身份本地消息时保持条目自身的 `at`(旧历史不编造发送时间)。 + */ +function sameIdentitySentAt( + itemId: string, + entryAt: number, + localSentAt: ReadonlyMap, +): number { + const local = localSentAt.get(itemId) ?? 0; + if (local <= 0) return entryAt; + if (entryAt <= 0) return local; + return Math.min(entryAt, local); +} + /** 连续的工具条目合成一块;中间夹了正文就分块。 */ function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { const merged: DirectChatBlock[] = []; @@ -160,6 +205,7 @@ export function buildDirectChatTurns({ }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; let current: DirectChatTurnEntries | null = null; + const localSentAt = localSentTimes(localMessages); // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 const leadingEntries: DirectChatEntry[] = []; @@ -217,7 +263,11 @@ export function buildDirectChatTurns({ const process: DirectChatBlock[] = []; const finals: DirectChatBlock[] = []; turn.entries.forEach((entry, index) => { - const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); + const block = blockFromEntry( + entry, + `${turn.key}:${entry.itemId}`, + localSentAt, + ); if (!block) return; if (block.kind === 'user') { users.push(block); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts index e085b1a43..5dff2ca0f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -20,6 +20,12 @@ import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; * 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 * `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, * 不能在前端收到或重放时重新取当前时间。 + * + * `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical + * itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 + * 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 + * + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 + * (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 */ export type DirectThreadEvent = | { @@ -28,6 +34,10 @@ export type DirectThreadEvent = * 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 */ at?: number; + /** + * 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + */ + userItemId?: string; } | { type: 'turn.completed'; @@ -36,6 +46,10 @@ export type DirectThreadEvent = * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ at?: number; + /** + * 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + */ + userItemId?: string; } | { type: 'item.started'; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index e1ecb5054..520299e8b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -1,7 +1,13 @@ import './resourceCanvasGenerationPanel.css'; import { Sparkles, X } from 'lucide-react'; -import { type CSSProperties, type FormEvent, useState } from 'react'; +import { + type CSSProperties, + type FormEvent, + useEffect, + useRef, + useState, +} from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -23,7 +29,9 @@ import { resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIdsMatch, resourceCanvasAssetGenerationReferenceIssue, + resourceCanvasAssetGenerationReferenceProblems, resourceCanvasAssetGenerationUserReferenceLimit, } from './resourceCanvasAssetGenerationReferenceModel'; import { @@ -88,6 +96,20 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { variant?: 'modal' | 'floating'; /** 浮层形态的定位样式(贴着占位卡下沿,与快速编辑 / 信息浮层同一条锚点口径)。 */ style?: CSSProperties | null; + /** + * 这份草稿是不是「某次已提交请求」的重试。 + * + * 失效参考的处置方式取决于它:重试的身份绑定**提交时那份参考集合**,改动参考再提交会被原生 + * 拒绝(也绝不会自动变成一次新的付费请求),所以面板要把这条说清楚,而不是只报一句失败原因。 + */ + retryOfRequest?: boolean; + /** + * 面板被卸载时(切到另一张占位 / 浮层被换掉)交出当前草稿。 + * + * 浮层按 `draftId` 分实例之后,切换等于卸载;不在这里交出草稿就等于把用户没提交的输入丢掉。 + * 显式关闭仍然走 `onClose(draft)`,两条路写的是同一个槽。 + */ + onDraftPersist?: (draft: ResourceCanvasAssetGenerationPanelDraft) => void; /** * 提交回调:**同步返回**,面板不等它的结果。 * @@ -133,6 +155,8 @@ export function ResourceCanvasAssetGenerationPanelView({ activeVersionId, variant = 'modal', style, + retryOfRequest = false, + onDraftPersist, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { @@ -150,6 +174,61 @@ export function ResourceCanvasAssetGenerationPanelView({ draft?.imageSize ?? action.imageSize, ); const [error, setError] = useState(initialError ?? null); + /** + * 原请求冻结下来的输入(只有「已提交请求的重试」才有)。 + * + * 已提交请求的身份由动作指纹(提示词、素材名、比例、尺寸等)定位,参考集合则进账本请求正文 + * 并在恢复时逐项比对:改掉其中任何一项,这次提交就不再是原请求的重试——可能被原生拒绝恢复, + * 也可能成为一次不同的付费意图,而用户以为自己只是在重试。所以这里冻结一份对照值、提交前 + * 逐项比对,不靠文案提醒。 + * 取挂载那一帧的 `draft`:宿主重建重试草稿时用的就是账本里原始请求的输入。 + */ + const boundRequestRef = useRef<{ + referenceIds: string[]; + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; + } | null>( + retryOfRequest + ? { + referenceIds: resourceCanvasAssetGenerationReferenceIds( + draft?.references ?? [], + ), + prompt: draft?.prompt ?? '', + assetName: draft?.assetName ?? action.assetName, + aspectRatio: draft?.aspectRatio ?? action.aspectRatio, + imageSize: draft?.imageSize ?? action.imageSize, + } + : null, + ); + /** 提交/显式关闭已经把这份输入交出去:卸载时不再往草稿槽里写回一份内存副本。 */ + const draftReleasedRef = useRef(false); + /** + * 卸载时把当前草稿交回宿主。 + * + * 每次渲染都刷新 ref(而不是订阅每次输入):面板可能被切占位直接卸载,卸载那一刻读到的必须 + * 是最新草稿。`onDraftPersist` 也走 ref,避免闭包被第一帧冻住。 + */ + const latestDraftRef = useRef({ + prompt: draft?.prompt ?? '', + assetName: draft?.assetName ?? action.assetName, + aspectRatio: draft?.aspectRatio ?? action.aspectRatio, + imageSize: draft?.imageSize ?? action.imageSize, + references: draft?.references ?? [], + }); + const persistDraftRef = useRef(onDraftPersist); + persistDraftRef.current = onDraftPersist; + useEffect( + () => () => { + // 已经交出去的输入不复活:提交成功或用户显式关闭之后,草稿槽归宿主与任务账本管。 + if (draftReleasedRef.current) { + return; + } + persistDraftRef.current?.(latestDraftRef.current); + }, + [], + ); // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); @@ -159,7 +238,8 @@ export function ResourceCanvasAssetGenerationPanelView({ * 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器, * 原生侧同样拒绝额外参考(不是静默丢弃)。 */ - const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action); + const referenceEnabled = + resourceCanvasAssetGenerationAcceptsReferences(action); const referenceLimit = resourceCanvasAssetGenerationUserReferenceLimit(action); const referenceAssets = resourceCanvasAssetGenerationReferenceAssets( @@ -181,23 +261,57 @@ export function ResourceCanvasAssetGenerationPanelView({ assets: assets ?? [], }) : null; + /** + * 失效参考逐条呈现,而不是只报第一条。 + * + * 重试时这条判据尤其重要:原请求的身份绑定提交那一刻的参考集合,把失效参考静默丢掉再提交, + * 轻则发了一次「没有参考」的付费生成,重则被原生拒绝恢复、用户还得自己收拾账本。 + */ + const referenceProblems = referenceEnabled + ? resourceCanvasAssetGenerationReferenceProblems({ + references, + assets: assets ?? [], + }) + : []; const promptTooLong = prompt.trim().length > promptMaxLength; const promptTooLongError = promptTooLong ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` : null; + /** + * 重试面板里「输入被改动过」的判据。 + * + * 这几项共同描述「原来那一份精确请求」:任何一项与冻结值不同,这次提交就不再是它的重试—— + * 原请求的恢复校验会拒绝(提示先对账),或按新的意图另行生成并计费。删掉 `@引用` 正文顺带 + * 删掉 chip、只改一个字,都会落到这里——所以判据必须逐项比对,不能只看参考是否失效。 + */ + const boundRequestChanged = + boundRequestRef.current !== null && + (!resourceCanvasAssetGenerationReferenceIdsMatch( + boundRequestRef.current.referenceIds, + referenceAssetIds, + ) || + prompt.trim() !== boundRequestRef.current.prompt.trim() || + assetName.trim() !== boundRequestRef.current.assetName.trim() || + aspectRatio !== boundRequestRef.current.aspectRatio || + imageSize !== boundRequestRef.current.imageSize); const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0 && !referenceError && !referenceIssue && - !promptTooLong; - const shownError = error ?? referenceIssue ?? promptTooLongError ?? referenceError; + !promptTooLong && + // 改了原请求的输入就不是重试:挡住提交,防止悄悄变成一次新的付费生成。 + !boundRequestChanged; + const shownError = + error ?? referenceIssue ?? promptTooLongError ?? referenceError; const applyDraft = (next: ChatComposerDraft) => { setPrompt(next.text); setReferences(next.references); }; /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ - const closeWithDraft = () => + const closeWithDraft = () => { + // 输入已经明确交回宿主:卸载时不再写回一份内存副本(不然「关闭」会复活已提交的草稿)。 + draftReleasedRef.current = true; onClose({ prompt, assetName, @@ -205,6 +319,37 @@ export function ResourceCanvasAssetGenerationPanelView({ imageSize, references, }); + }; + // 卸载(切占位 / 换浮层)时交出的就是这一帧的草稿。 + latestDraftRef.current = { + prompt, + assetName, + aspectRatio, + imageSize, + references, + }; + /** + * 失效参考的处置说明。 + * + * 三件事必须一起说清,否则用户只会看到「提交按钮点不动」:哪一条参考失效了;原请求的身份绑定 + * 提交时那份参考集合,改参考再点「重试」会被原生拒绝、也不会自动变成新请求;以及两条出路 + * (恢复原参考后重试 / 关闭浮层另起一次生成,后者是新的付费请求)。 + */ + const referenceProblemNotice = + referenceProblems.length === 0 + ? null + : [ + ...referenceProblems.map((problem) => problem.reason), + retryOfRequest + ? '这条请求的身份绑定提交时那份参考集合,改了参考再重试会被原生拒绝,也不会自动改成新的请求。' + : null, + '请先把失效参考恢复成可用的同一张素材再重试;要换别的参考,请关闭浮层后从工具栏重新发起一次生成——那是一次新的生成,可能产生新费用。', + ] + .filter((line): line is string => Boolean(line)) + .join(''); + const boundRequestNotice = boundRequestChanged + ? '这次生成是「原请求重试」:请求身份绑定提交时那份输入(参考集合、提示词、素材名、比例、尺寸)。改动任何一项,这次提交就不再是原请求的重试,可能产生新的计费,所以这里挡住了提交。请把输入改回原样后再重试;要按新输入生成,请关闭浮层后从工具栏重新发起一次——那是一次新的生成,可能产生新费用。' + : null; function submit(event: FormEvent) { event.preventDefault(); @@ -216,11 +361,15 @@ export function ResourceCanvasAssetGenerationPanelView({ !normalizedAssetName || referenceError || referenceIssue || - promptTooLong + promptTooLong || + // 按钮禁用只是表现:改了原请求输入的提交在这里也必须被挡住。 + boundRequestChanged ) { return; } setError(null); + // 这次输入被任务接走:卸载时不再写回草稿槽(失败重开由宿主的提交上下文负责)。 + draftReleasedRef.current = true; // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 // (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。 onSubmit({ @@ -349,7 +498,23 @@ export function ResourceCanvasAssetGenerationPanelView({ {`参考图 ${referenceAssetIds.length}/${referenceLimit}`}

) : null} - {shownError ? ( + {referenceProblemNotice ? ( +

+ {referenceProblemNotice} +

+ ) : boundRequestNotice ? ( +

+ {boundRequestNotice} +

+ ) : shownError ? (

{shownError}

diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index df8761bcb..de592086d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -1,5 +1,11 @@ import { Sparkles, X } from 'lucide-react'; -import { type CSSProperties, type FormEvent, useRef, useState } from 'react'; +import { + type CSSProperties, + type FormEvent, + useEffect, + useRef, + useState, +} from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -63,6 +69,24 @@ export type ResourceCanvasGenerationPanelViewProps = { * 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。 */ request?: ResourceEditRequestIdentity | null; + /** + * 上一次失败的原因(占位上的失败态)。 + * + * 失败**属于那张占位**,不属于面板实例:换到另一张占位必须看不到它的原因。重开同一张占位时 + * 又要能看见,所以由宿主从占位读出来传进来,面板只负责呈现。 + */ + initialError?: string | null; + /** + * 面板被卸载时(切到另一张占位)交出当前草稿。 + * + * 浮层按 `draftId` 分实例之后,切换等于卸载;不在这里交出草稿就等于把用户没提交的输入丢掉。 + * 显式关闭仍然走 `onClose(draft)`,两条路写的是同一个槽。 + */ + onDraftPersist?: (draft: { + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + }) => void; onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; /** * 收起浮层。 @@ -107,6 +131,8 @@ export function ResourceCanvasGenerationPanelView({ style, initialDraft, request: boundRequest, + initialError, + onDraftPersist, onSubmit, onClose, }: ResourceCanvasGenerationPanelViewProps) { @@ -131,6 +157,44 @@ export function ResourceCanvasGenerationPanelView({ const [attempted, setAttempted] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // 占位带来的失败原因优先展示;面板自己这次的失败(`error`)覆盖它。 + const shownError = error ?? initialError ?? null; + /** + * 已绑定请求身份的那句提示词(只有带 `request` 重开的失败面板才有)。 + * + * Rust 的请求指纹含 prompt:提示词一变,`resolveResourceEditRequestIdentity` 就会重铸一对 + * 新的 operationId / 幂等键,这次提交也就不再是原请求的重试(旧账本无法按原样恢复,新身份 + * 对应另一次生成与计费)。用户以为自己还在重试,所以这里把边界钉住:改动了提示词就挡提交, + * 显式要求"改回原样"或"另起一次新生成"。 + */ + const boundRequestPromptRef = useRef( + boundRequest?.prompt ?? null, + ); + /** 提交/显式关闭已经把输入交出去了:卸载时不再往草稿槽里写一份内存副本。 */ + const draftReleasedRef = useRef(false); + /** + * 卸载时把当前草稿交回宿主:切换占位会卸载面板,草稿不能跟着实例一起消失。 + */ + const latestDraftRef = useRef<{ + kind: ResourceCanvasGenerationKind; + prompt: string; + assetName: string; + }>({ + kind, + prompt, + assetName: assetName.trim() || option.assetName, + }); + const persistDraftRef = useRef(onDraftPersist); + persistDraftRef.current = onDraftPersist; + useEffect( + () => () => { + if (draftReleasedRef.current) { + return; + } + persistDraftRef.current?.(latestDraftRef.current); + }, + [], + ); // 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸 // (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定 // 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。 @@ -139,14 +203,34 @@ export function ResourceCanvasGenerationPanelView({ ); const inputLocked = attempted || submitting; /** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */ - const closeWithDraft = () => + const closeWithDraft = () => { + draftReleasedRef.current = true; onClose({ kind, prompt, assetName: assetName.trim() || option.assetName }); + }; + /** + * 重开失败面板时改动提示词:这不是重试,而是一次新请求(新 operationId / 幂等键 = 新计费)。 + */ + const boundRequestPromptChanged = + boundRequestPromptRef.current !== null && + prompt.trim() !== boundRequestPromptRef.current.trim(); + // 卸载(切占位)时交出的就是这一帧的草稿。 + latestDraftRef.current = { + kind, + prompt, + assetName: assetName.trim() || option.assetName, + }; async function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName || submitting) { + if ( + !normalizedPrompt || + !normalizedAssetName || + submitting || + // 按钮禁用只是表现:改动原请求提示词的提交在这里也必须被挡住,不能悄悄变成新付费生成。 + boundRequestPromptChanged + ) { return; } setAttempted(true); @@ -164,6 +248,8 @@ export function ResourceCanvasGenerationPanelView({ prompt: normalizedPrompt, assetName: normalizedAssetName, }); + // 只有**成功**交出这次输入才不再写回草稿;失败时草稿要留给用户切走再切回来的重试。 + draftReleasedRef.current = true; } catch (submitError) { setError(resourceGenerationErrorMessage(submitError)); } finally { @@ -234,9 +320,17 @@ export function ResourceCanvasGenerationPanelView({ disabled={inputLocked} applyPrompt={setPrompt} /> - {error ? ( + {boundRequestPromptChanged ? ( +

+ 这次生成是「原请求重试」:请求身份绑定提交时那句提示词。改动提示词就不再是原请求的重试(会另行生成并计费),所以这里挡住了提交。请把提示词改回原样后再重试;要按新提示词生成,请关闭浮层后从工具栏重新发起一次——那是一次新的生成,可能产生新费用。 +

+ ) : shownError ? (

- {error} + {shownError}

) : null}
@@ -247,8 +341,11 @@ export function ResourceCanvasGenerationPanelView({ > {submitting ? '后台运行并关闭' : '取消'} - {error ? ( - + {shownError ? ( + @@ -256,7 +353,11 @@ export function ResourceCanvasGenerationPanelView({
} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + + ); +} + async function settle() { await act(async () => { await Promise.resolve(); @@ -277,8 +372,7 @@ async function openBgmEntry() { if (!placeholder) { throw new Error('点音频入口后没有出现占位卡'); } - const draftId = - placeholder.dataset.resourceCanvasGenerationPlaceholder ?? ''; + const draftId = placeholder.dataset.resourceCanvasGenerationPlaceholder ?? ''; const prompt = document.querySelector( 'textarea', ) as HTMLTextAreaElement; @@ -295,9 +389,8 @@ async function openBgmEntry() { function placeholderWorldPoint(element: HTMLElement) { const styled = element.closest('[style*="translate"]'); const source = styled?.style.transform ?? ''; - const [x, y] = Array.from( - source.matchAll(/-?\d+(?:\.\d+)?/g), - (match) => Number(match[0]), + const [x, y] = Array.from(source.matchAll(/-?\d+(?:\.\d+)?/g), (match) => + Number(match[0]), ); return { x, y }; } @@ -345,11 +438,79 @@ function panelSubmitButton(label: string) { return submit; } +/** 当前挂着的生成浮层(音频与图片类共用同一个数据属性)。 */ +function floatingPanel() { + return document.querySelector( + '[data-resource-canvas-generation-floating-panel]', + ); +} + +function floatingPanelPrompt() { + return ( + floatingPanel()?.querySelector('textarea') ?? null + ); +} + +/** 浮层里的提交按钮:不按文案挑,音频失败态会换成「使用原请求重试」。 */ +function floatingPanelSubmit() { + const submit = floatingPanel()?.querySelector( + 'button[type="submit"]', + ); + if (!submit) { + throw new Error('没找到浮层里的提交按钮'); + } + return submit; +} + +/** 浮层里存在某文案的按钮吗(工具栏上的同名按钮要排掉)。 */ +function floatingPanelHasButton(label: string) { + const panel = floatingPanel(); + if (!panel) { + return false; + } + return Array.from(panel.querySelectorAll('button')).some( + (button) => button.textContent?.trim() === label, + ); +} + +/** 底部工具栏上的工具按钮(与浮层里的同名提交按钮区分开)。 */ +function toolbarToolButton(label: string) { + const button = screen + .getAllByRole('button', { name: label }) + .find((candidate) => candidate.closest('.game-resource-bottom-toolbar')); + if (!button) { + throw new Error(`没找到工具栏上的工具「${label}」`); + } + return button; +} + +/** 打开某个资源栏目:点总览卡入口、切到「按类型」,布局 hook 就绪后工具栏才出现。 */ +async function openResourceCategory(entryLabel: string) { + const opener = screen + .getAllByRole('button') + .find( + (button) => + /^打开/.test(button.textContent?.trim() ?? '') && + button.textContent?.includes(entryLabel), + ); + if (!opener) { + throw new Error(`没找到栏目入口「${entryLabel}」`); + } + fireEvent.click(opener); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await settle(); +} + +/** 点击音频栏目的某条工具:占位与浮层都由这一个动作产生。 */ +async function openAudioTool(toolLabel: string) { + fireEvent.click(toolbarToolButton(toolLabel)); + await settle(); +} + afterEach(() => { cleanup(); - delete ( - window as unknown as { __TAURI__?: unknown } - ).__TAURI__; + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; vi.restoreAllMocks(); }); @@ -402,20 +563,16 @@ describe('画布生成入口的宿主生命周期', () => { (position) => position.resourceId === NEW_RESOURCE_ID, ), ) - .map( - (write) => { - const landed = write.positions.find( - (position) => position.resourceId === NEW_RESOURCE_ID, - )!; - return `${write.mode}:${landed.section}@${landed.x},${landed.y}${ - landed.manuallyPlaced ? 'M' : 'A' - }`; - }, - ) + .map((write) => { + const landed = write.positions.find( + (position) => position.resourceId === NEW_RESOURCE_ID, + )!; + return `${write.mode}:${landed.section}@${landed.x},${landed.y}${ + landed.manuallyPlaced ? 'M' : 'A' + }`; + }) .join(' || '), - ).toContain( - `type:audio@${placeholderPoint.x},${placeholderPoint.y}M`, - ); + ).toContain(`type:audio@${placeholderPoint.x},${placeholderPoint.y}M`); }); await waitFor(() => expect(allPlaceholders()).toHaveLength(0)); }); @@ -459,3 +616,245 @@ describe('画布生成入口的宿主生命周期', () => { }); }); }); + +/** + * 浮层**按占位隔离**的宿主回归。 + * + * 浮层是非模态的:面板开着时用户仍能点另一条工具、另一张占位卡。这组用例钉住三件在宿主里才能 + * 看到的事: + * 1. 换到另一张占位必须换一份面板状态(类型、输入、失败原因都不跟着走),而**未提交草稿必须留下**; + * 2. 失败请求的操作身份只属于它自己那张占位:在别的占位上提交是一次新请求,回到原占位才是重试; + * 3. 参考素材被删掉后重试:失效参考必须显式呈现并挡住提交,不许静默丢参考、也不许再发一次请求。 + */ +describe('生成浮层按占位隔离(宿主回归)', () => { + test('同类不同草稿:切到另一条音频工具是新面板,切回来原草稿还在', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + expect(floatingPanelHasButton('生成背景音乐')).toBe(true); + expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); + + // 面板开着直接点另一条音频工具:这是另一张占位,必须是另一份面板状态。 + await openAudioTool('生成音效'); + expect(floatingPanelHasButton('生成音效')).toBe(true); + expect(floatingPanelHasButton('生成背景音乐')).toBe(false); + expect(floatingPanelPrompt()?.value ?? '').toBe(''); + // 全程只点入口与输入,没有任何生成请求被发起。 + expect(tauri.deriveCalls).toEqual([]); + + // 切回第一张占位:未提交草稿不能因为面板卸载而丢。 + fireEvent.click(placeholderElement(draftId)!); + await settle(); + expect(floatingPanelHasButton('生成背景音乐')).toBe(true); + expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); + }); + + test('失败态切换:失败原因与操作身份都不跟着换占位', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [ + { ok: false, message: 'result-unknown: 测试网络中断' }, + { ok: true, assetId: NEW_ASSET_ID }, + ], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(1); + await waitFor(() => + expect(floatingPanel()?.textContent ?? '').toContain('测试网络中断'), + ); + + // 切到音效:上一条的失败原因与「输入已锁定」都不得跟着它走。 + await openAudioTool('生成音效'); + expect(floatingPanel()?.textContent ?? '').not.toContain('测试网络中断'); + expect(floatingPanelPrompt()?.disabled).toBe(false); + expect(floatingPanelPrompt()?.value ?? '').toBe(''); + + // 在音效面板提交:这是**新的**请求,不能复用背景音乐那条失败请求的操作身份。 + await typeGenerationPrompt(floatingPanel()!, '一段清脆的铃声'); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(2); + expect(tauri.deriveCalls[1]!.operationId).not.toBe( + tauri.deriveCalls[0]!.operationId, + ); + expect(tauri.deriveCalls[1]).toMatchObject({ editKind: 'sound-effect' }); + + // 回到原占位重试:必须复用原请求的操作身份与幂等键(同一 operation 账本)。 + fireEvent.click(placeholderElement(draftId)!); + await settle(); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(3); + expect(tauri.deriveCalls[2]).toMatchObject({ + operationId: tauri.deriveCalls[0]!.operationId, + idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey, + prompt: tauri.deriveCalls[0]!.prompt, + }); + }); + + test('失效参考:保留原 ID 并挡住重试,不静默丢参考也不发新请求', async () => { + const user = userEvent.setup(); + const tauri = installHostTauri({ + assets: [imageAsset('ref-img', 'ref.png')], + deriveResults: [], + assetGenerationRecord: 'failed', + }); + render( + , + ); + await settle(); + + await openResourceCategory('角色'); + fireEvent.click(toolbarToolButton('生成图片')); + await settle(); + const draftId = + allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; + expect(draftId).not.toBe(''); + + // 真选一张参考图:候选来自当前清单。 + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + const panel = floatingPanel()!; + await user.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click(within(picker).getByRole('option', { name: /ref/ })); + await user.click(within(picker).getByRole('button', { name: '插入引用' })); + await waitFor(() => + expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), + ); + + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + expect(tauri.assetGenerationStarts[0]!.referenceAssetIds).toEqual([ + 'ref-img', + ]); + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 参考素材被删掉之后重试:失效参考必须留在草稿里并被显式报出来。 + fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + + const problem = floatingPanel()?.querySelector( + '[data-resource-canvas-generation-reference-problem]', + ); + expect(problem?.textContent ?? '').toContain('已不在当前项目'); + const submit = floatingPanelSubmit(); + expect(submit.disabled).toBe(true); + fireEvent.click(submit); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + }); + + test('已提交请求的重试:删掉失效参考再提交零新增请求,恢复原参考后才允许重试', async () => { + const user = userEvent.setup(); + const tauri = installHostTauri({ + assets: [imageAsset('ref-img', 'ref.png')], + deriveResults: [], + assetGenerationRecord: 'failed', + }); + render( + , + ); + await settle(); + + await openResourceCategory('角色'); + fireEvent.click(toolbarToolButton('生成图片')); + await settle(); + const draftId = + allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + const panel = floatingPanel()!; + await user.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click(within(picker).getByRole('option', { name: /ref/ })); + await user.click(within(picker).getByRole('button', { name: '插入引用' })); + await waitFor(() => + expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), + ); + + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + const originalPrompt = tauri.assetGenerationStarts[0]!.prompt; + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 素材被删掉后重开重试面板:草稿里的失效参考被显式保留并挡住提交。 + fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + expect( + floatingPanel()?.querySelector( + '[data-resource-canvas-generation-reference-problem]', + ), + ).not.toBeNull(); + + /* + 用户直接在正文里删掉 `@引用`:参考集合变空、失效参考的问题随之消失,但**输入已经不是 + 原请求的那份**——原生按指纹当新请求处理,也就是一次新的付费生成。这里必须挡住, + 而不是让「旧 draft 悄悄变成新收费」。 + */ + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + expect( + floatingPanel()?.querySelector( + '[data-resource-canvas-generation-bound-request-changed]', + ), + ).not.toBeNull(); + const blockedSubmit = floatingPanelSubmit(); + expect(blockedSubmit.disabled).toBe(true); + fireEvent.click(blockedSubmit); + // 绕过按钮禁用直接提交表单也一样:处理函数自己再挡一次。 + fireEvent.submit(floatingPanel()!.querySelector('form')!); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + + // 恢复原参考(同一 id)+ 重开面板拿回冻结草稿:这才是原请求的重试。 + fireEvent.click(screen.getByRole('button', { name: '恢复参考素材' })); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + const retrySubmit = floatingPanelSubmit(); + expect(retrySubmit.disabled).toBe(false); + fireEvent.click(retrySubmit); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(2); + expect(tauri.assetGenerationStarts[1]!.referenceAssetIds).toEqual([ + 'ref-img', + ]); + expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index a281f6150..6fb839e58 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -114,7 +114,9 @@ function installTauri({ 生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态), 但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。 */ - assets: [...assets, generatedAsset()].map((asset) => structuredClone(asset)), + assets: [...assets, generatedAsset()].map((asset) => + structuredClone(asset), + ), }; const invoke = vi.fn( async (command: string, args?: Record) => { @@ -257,7 +259,11 @@ async function openCategory(label: string) { ); } -function Workbench({ assets }: { assets: GameCreationAppAssetManifestEntry[] }) { +function Workbench({ + assets, +}: { + assets: GameCreationAppAssetManifestEntry[]; +}) { const [manifest, setManifest] = useState(() => ({ ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), assets: assets.map((asset) => structuredClone(asset)), @@ -294,7 +300,9 @@ describe('图片生成落点', () => { const tauri = installTauri({ assets: [pngAsset('asset-character', 'character.png')], }); - render(); + render( + , + ); await openCategory('角色与对象'); fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); @@ -385,7 +393,10 @@ function installCanvasRectStubs(canvasTop = 32) { height: boxHeight, toJSON: () => ({}), }) as DOMRect; - Object.defineProperty(canvas, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(canvas, 'clientWidth', { + configurable: true, + value: width, + }); Object.defineProperty(canvas, 'clientHeight', { configurable: true, value: height, @@ -460,10 +471,14 @@ describe('音频生成身份', () => { render(); await openCategory('音频'); - fireEvent.click(await screen.findByRole('button', { name: '生成背景音乐' })); + fireEvent.click( + await screen.findByRole('button', { name: '生成背景音乐' }), + ); const panel = await screen.findByRole('dialog', { name: '生成背景音乐' }); await typeGenerationPrompt(panel, '轻快的八音盒'); - fireEvent.click(within(panel).getByRole('button', { name: '生成背景音乐' })); + fireEvent.click( + within(panel).getByRole('button', { name: '生成背景音乐' }), + ); await waitFor(() => expect(tauri.deriveInputs).toHaveLength(1)); const firstOperationId = tauri.deriveInputs[0]?.operationId; expect(typeof firstOperationId).toBe('string'); @@ -492,8 +507,13 @@ describe('音频生成身份', () => { const reopened = await screen.findByRole('dialog', { name: '生成背景音乐', }); + /* + 重开的浮层带着**那张占位自己的**失败原因:按钮也就从「生成背景音乐」换成 + 「使用原请求重试」。失败原因不再随面板实例留在这儿,换到别的占位看不到它。 + */ + expect(reopened.textContent).toContain('远端拒绝'); fireEvent.click( - within(reopened).getByRole('button', { name: '生成背景音乐' }), + within(reopened).getByRole('button', { name: '使用原请求重试' }), ); await waitFor(() => expect(tauri.deriveInputs).toHaveLength(2)); expect(tauri.deriveInputs[1]?.operationId).toBe(firstOperationId); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index dc734438a..18452ec28 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -1323,10 +1323,81 @@ describe('资源画布多选拖动与整理范围', () => { return { cardA, cardB }; } + it.each(['character', 'all'] as const)( + '%s:多选拖动期间布局重建,预览仍使用按下时起点并与松手写回一致', + async (target) => { + const { manager, viewport, tauri } = await mountPointerWorkbench(target, { + layoutByMode: { type: twoCharacterCards }, + }); + const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); + const scale = viewport()[2]!; + fireEvent.pointerDown(cardB, { + pointerId: 49, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 49, + buttons: 1, + clientX: 180, + clientY: 140, + }); + const beforeA = [ + cardA.style.getPropertyValue('--resource-x'), + cardA.style.getPropertyValue('--resource-y'), + ]; + const beforeB = [ + cardB.style.getPropertyValue('--resource-x'), + cardB.style.getPropertyValue('--resource-y'), + ]; + // 重建底层布局,模拟拖动尚未结束时另一笔布局操作完成。 + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + expect( + typeWrites(tauri)[0]!.positions.find( + (p) => p.resourceId === 'asset:pointer-b', + )?.y, + ).toBe(0); + expect([ + cardA.style.getPropertyValue('--resource-x'), + cardA.style.getPropertyValue('--resource-y'), + ]).toEqual(beforeA); + expect([ + cardB.style.getPropertyValue('--resource-x'), + cardB.style.getPropertyValue('--resource-y'), + ]).toEqual(beforeB); + fireEvent.pointerUp(cardB, { + pointerId: 49, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); + expect(typeWrites(tauri)[1]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:pointer-a', + x: Math.round(80 / scale), + y: Math.round(40 / scale), + }), + expect.objectContaining({ + resourceId: 'asset:pointer-b', + x: Math.round(400 + 80 / scale), + y: Math.round(300 + 40 / scale), + }), + ]), + ); + }, + ); + it('拖动已选卡整批等位移、一笔提交、一次撤销,多选保持', async () => { - const { manager, viewport, tauri } = await mountPointerWorkbench('character', { - layoutByMode: { type: twoCharacterCards }, - }); + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); const scale = viewport()[2]!; expect(scale).toBeGreaterThan(0); @@ -1398,9 +1469,12 @@ describe('资源画布多选拖动与整理范围', () => { }); it('不同缩放下多选位移按 scale 换算', async () => { - const { manager, viewport, tauri } = await mountPointerWorkbench('character', { - layoutByMode: { type: twoCharacterCards }, - }); + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); const { cardB } = await selectTwoCharacterCards(manager, tauri); const before = viewport()[2]!; @@ -1445,9 +1519,12 @@ describe('资源画布多选拖动与整理范围', () => { }); it('拖动未选中的卡保持单选语义:只有它自己动', async () => { - const { manager, viewport, tauri } = await mountPointerWorkbench('character', { - layoutByMode: { type: twoCharacterCards }, - }); + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); const cardA = cardIn(manager, 'asset:pointer-a'); const cardB = cardIn(manager, 'asset:pointer-b'); @@ -1536,7 +1613,11 @@ describe('资源画布多选拖动与整理范围', () => { assets: [ pngAsset('pointer-a', 'a.png'), pngAsset('pointer-b', 'b.png'), - { ...pngAsset('scene-c', 'scene-c.png'), category: 'scene', kind: 'scene' }, + { + ...pngAsset('scene-c', 'scene-c.png'), + category: 'scene', + kind: 'scene', + }, ], layoutByMode: { type: [ @@ -1771,9 +1852,7 @@ describe('资源画布多选拖动与整理范围', () => { const cardB = cardIn(manager, 'asset:pointer-b'); fireEvent.click(cardA); fireEvent.click(cardB, { shiftKey: true }); - await waitFor(() => - expect(selectedResourceIdsInDom()).toHaveLength(2), - ); + await waitFor(() => expect(selectedResourceIdsInDom()).toHaveLength(2)); fireEvent.pointerDown(cardB, { pointerId: 45, @@ -1791,9 +1870,7 @@ describe('资源画布多选拖动与整理范围', () => { fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); await waitFor(() => expect( - tauri.layoutReads.some( - (read) => read.projectPath === projectPathB, - ), + tauri.layoutReads.some((read) => read.projectPath === projectPathB), ).toBe(true), ); // 松手发生在切项目之后:位移不写进新项目。 diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index ab6201a00..4f8f84402 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1559,7 +1559,9 @@ describe('资源画布整理与批量坐标写入', () => { return { ...resource(id), category: 'character' }; } - function typeLayoutHarness(initialPositions: ProjectResourceCanvasPosition[]) { + function typeLayoutHarness( + initialPositions: ProjectResourceCanvasPosition[], + ) { const updates: ProjectResourceCanvasPosition[][] = []; const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1704,8 +1706,14 @@ describe('资源画布整理与批量坐标写入', () => { const characterB = characterResource('resource-char-auto-b'); const { updates } = typeLayoutHarness([ position('resource-doc-a', 600, 40), - { ...automaticPosition('resource-char-auto-a', 900, 900), section: 'character' }, - { ...automaticPosition('resource-char-auto-b', 1100, 940), section: 'character' }, + { + ...automaticPosition('resource-char-auto-a', 900, 900), + section: 'character', + }, + { + ...automaticPosition('resource-char-auto-b', 1100, 940), + section: 'character', + }, ]); const { result } = renderHook(() => @@ -1752,7 +1760,10 @@ describe('资源画布整理与批量坐标写入', () => { const characterA = characterResource('resource-char-a'); const { updates } = typeLayoutHarness([ position('resource-doc-a', 600, 40), - { ...automaticPosition('resource-char-a', 900, 900), section: 'character' }, + { + ...automaticPosition('resource-char-a', 900, 900), + section: 'character', + }, ]); const { result } = renderHook(() => @@ -1798,11 +1809,12 @@ describe('资源画布整理与批量坐标写入', () => { it('排队中的整理不会被「关系图首次就绪」覆盖:两者各占一个队列槽', async () => { const documentA = resource('resource-doc-a'); const documentB = resource('resource-doc-b'); - const { updates, holdNextWrite, releaseHeldWrite } = - gatedTypeLayoutHarness([ + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ position('resource-doc-a', 600, 40), automaticPosition('resource-doc-b', 900, 900), - ]); + ], + ); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1853,6 +1865,357 @@ describe('资源画布整理与批量坐标写入', () => { expect(updates).toHaveLength(2); }); + /** + * 两栏各按一次「整理画布」:必须是**两笔独立意图**,各占一个队列槽、按按压顺序落盘, + * 后一栏不得覆盖前一栏。 + * + * 触发窗口是真实的:上一笔还在途(拖动落点没写回)时按下第一栏整理,随后切到第二栏 + * 再按一次。**覆盖式**的合并键(只判"是不是整理")会把第一栏那笔的目标范围改写成第二栏, + * 于是画面上第一栏已经被乐观重排过、磁盘上却只剩第二栏的整理。两笔各自落盘同时保证 + * 两栏各自对应一次可撤销操作:调用方一次按压只记一条历史,这里用"一笔一次写入、顺序不变" + * 把这条 hook 级契约钉住。 + */ + it('不同栏目的整理各占一个队列槽:在途手动写之后两栏先后落盘,前一栏不被后一栏覆盖', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const characterA = characterResource('resource-char-a'); + const characterB = characterResource('resource-char-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + { ...position('resource-char-a', 777, 55), section: 'character' }, + { + ...automaticPosition('resource-char-b', 1200, 1300), + section: 'character', + }, + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB, characterA, characterB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 拖动落点停在途上:两次整理都只能排队——正是覆盖式合并会把前一栏吃掉的窗口。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + let documentOrganized = false; + let characterOrganized = false; + act(() => { + documentOrganized = result.current.organizeNow(['document']); + }); + act(() => { + characterOrganized = result.current.organizeNow(['character']); + }); + // 两次按压都真的排进了队列:调用方据此各记一条撤销历史。 + expect(documentOrganized).toBe(true); + expect(characterOrganized).toBe(true); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + await waitFor(() => expect(updates).toHaveLength(3)); + + // 第一笔:拖动落点先落盘。 + expect( + updates[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + + // 第二笔:只整理 document,character 的手动/自动坐标逐值不动。 + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + section: 'document', + x: 196, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 777, + y: 55, + manuallyPlaced: true, + }), + expect.objectContaining({ + resourceId: 'resource-char-b', + section: 'character', + x: 1200, + y: 1300, + manuallyPlaced: false, + }), + ]), + ); + + // 第三笔:整理 character,且**保留**第一栏已经落盘的整理结果。 + expect(updates[2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-b', + section: 'character', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + // 最终画布上两栏都是整理后的自动坐标。 + expect( + result.current.layout.positions.filter((entry) => !entry.manuallyPlaced), + ).toHaveLength(4); + }); + + /** + * 同一栏连续按两次整理仍然只占一个队列槽(结果同源,重复按压不该多写一笔 CAS), + * 合并是**就地**的:它不会把自己排到队尾,先按下的手动落点依旧先落盘。 + */ + it('同一栏目连续按两次整理合并成一笔,且不改排在途写入之后的顺序', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + // 只有两笔:在途手动落点 + 一次合并后的整理。 + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + section: 'document', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + /** + * 排队中的整理属于它被按下时的那个 scope:切 mode(或切项目)时随旧队列一起取消, + * 绝不写进新 scope。判据是"旧 scope 只留下那一笔在途写入",没有额外的整理写入。 + */ + it('整理排队后又手动移动同一栏、再按整理:最后一次整理必须排在手动写入之后落盘', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 第一笔手动落点停在途上,后面三次动作只能排队。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + // ① 整理排进队列;② 用户又把同一张卡手动挪走;③ 再按一次整理。 + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 333, 444); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + // 四笔,顺序就是用户按下/松手的顺序:手动 → 整理 → 手动 → 整理。 + // 任何把第三次动作合并回**队首那个同名整理**的实现都会少一笔,并让最后落盘的 + // 变成中间那次手动坐标——用户最后按下的整理被静默吞掉。 + await waitFor(() => expect(updates).toHaveLength(4)); + expect( + updates[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + expect( + updates[1]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + // 中间那次手动移动没有被吞掉:它照样排在队首整理之后落盘。 + expect( + updates[2]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 333, y: 444, manuallyPlaced: true }); + // 最后一次整理在手动写入之后落盘,最终布局是自动坐标。 + expect( + updates[3]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + expect( + result.current.layout.positions.find( + (entry) => entry.resourceId === 'resource-doc-a', + ), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + }); + + /** + * 排队中的整理属于它被按下时的那个 scope:切 mode(或切项目)时随旧队列一起取消, + * 绝不写进新 scope。判据是"旧 scope 只留下那一笔在途写入",没有额外的整理写入。 + */ + it('切 mode 时排队中的整理随旧 scope 一起取消,不写进新 scope', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const typeWrites: ProjectResourceCanvasPosition[][] = []; + const dependencyWrites: ProjectResourceCanvasPosition[][] = []; + let releaseHeldTypeWrite: (() => void) | null = null; + let holdNextTypeWrite = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + const mode = args?.mode as ProjectResourceCanvasLayoutMode; + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout(mode, mode === 'type' ? 7 : 3, [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + if (mode === 'dependency') { + dependencyWrites.push(positions); + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + 4 + dependencyWrites.length, + positions, + ), + }; + } + if (holdNextTypeWrite) { + holdNextTypeWrite = false; + await new Promise((resolve) => { + releaseHeldTypeWrite = resolve; + }); + } + typeWrites.push(positions); + return { + status: 'updated', + layout: persistedLayout('type', 8 + typeWrites.length, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode, + resources: [documentA, documentB], + }), + { initialProps: { mode: 'type' as const } }, + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + typeWrites.length = 0; + + // 拖动落在途上,随后按下整理:整理只能排队,旧 scope 的队列这时还没被取消。 + holdNextTypeWrite = true; + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + // 切到另一个 mode:旧 scope 的排队意图必须一起作废。 + rerender({ mode: 'dependency' }); + await act(async () => { + releaseHeldTypeWrite?.(); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.layout.mode).toBe('dependency')); + await waitFor(() => expect(result.current.saving).toBe(false)); + + // 旧 scope 只留下那笔在途的手动写入,排队中的整理没有跟着落盘。 + expect(typeWrites).toHaveLength(1); + expect( + typeWrites[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + expect(result.current.layout.mode).toBe('dependency'); + }); + /** * 「刚拖完、落点还没写回」时按整理:整理必须照常排进队列并最终生效。 * @@ -1863,11 +2226,12 @@ describe('资源画布整理与批量坐标写入', () => { it('拖动落点还在途时按整理:整理照常排队,最终把这张卡重排回自动槽位', async () => { const documentA = resource('resource-doc-a'); const documentB = resource('resource-doc-b'); - const { updates, holdNextWrite, releaseHeldWrite } = - gatedTypeLayoutHarness([ + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ automaticPosition('resource-doc-a', 0, 0), automaticPosition('resource-doc-b', 196, 0), - ]); + ], + ); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1931,14 +2295,16 @@ describe('资源画布整理与批量坐标写入', () => { ...resource('resource-shift'), category: 'scene', }; - const { updates, holdNextWrite, releaseHeldWrite } = - gatedTypeLayoutHarness([ - { ...automaticPosition('resource-shift', 100, 100), section: 'document' }, - ]); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + { + ...automaticPosition('resource-shift', 100, 100), + section: 'document', + }, + ], + ); const { result, rerender } = renderHook( - (props: { - resources: ResourceCanvasItem[]; - }) => + (props: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ projectPath, projectId, @@ -2006,7 +2372,9 @@ describe('资源画布整理与批量坐标写入', () => { it('没有可整理栏目时不产生任何写入', async () => { const documentA = resource('resource-doc-a'); - const { updates } = typeLayoutHarness([position('resource-doc-a', 600, 40)]); + const { updates } = typeLayoutHarness([ + position('resource-doc-a', 600, 40), + ]); const { result } = renderHook(() => useProjectResourceCanvasLayout({ diff --git a/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md b/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md index ac310bad5..2dc6568d1 100644 --- a/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md +++ b/docs/project-memory/plans/【实施计划】对话总耗时与动态工具计时-2026-09-18.md @@ -31,3 +31,15 @@ ## 收口 已完成本轮实现、独立 review 返修与自动化/模拟浏览器验证,证据见对应里程碑。用户已授权更新 Issue/PR 并推送当前功能分支;不得据此合并 PR、发布或恢复定时任务。真实客户端端到端验收尚待完成,因此保留计划,卡片全面重设计和画布内任务浮层不计入本轮已交付内容。 + +## PR 补审返修(2026-09-18) + +用户确认修复补审发现:保留同身份乐观消息的真实发送时间;live 为空时仍向明确的本轮用户条目保存终态边界。先补失败回归,再最小修改原 reducer/回合投影与必要的 App 接线,不新增持久化 schema 或平行回合状态源。取消后迟到终态只补可验证测试,不能仅凭时间大小改变现役事件顺序合同。独立复核后运行定向与集成验证。本次 fix 不自动授权新的远程写入。 + +补审返修中已复现 bootstrap 只有生命周期事件、用户消息经 history slice 回读且 live 恒为空的缺口。前端无法证明归属,因此批准原生在 turn.started/turn.completed 上透传现有用户条目 userItemId;沿用原事件与已有消息身份,不另造 turnId。前端按精确 itemId 补时间边界,移除按历史尾项或时间戳相等猜测用户归属的兜底;完成先于历史到达时,在历史回读后同样按该身份补齐。原生与 TypeScript 绑定同批更新并验证重放。 + +### 返修结果 + +已完成同身份发送时间保留、原生 userItemId 关联、live 为空或终态先于历史时的精确回填;旧回合终态及取消回包不能关闭另一条已知身份的新请求。计时 40 项定向、appSurface 462 项(17 跳过)通过,原生身份序列化/重放/取消用例 3 项及原阶段时间序列化 1 项通过;绑定由 ts-rs 导出,Rust all-targets locked offline check 通过。 + +Edge 真实浏览器使用生产 reducer/组件和模拟原生事件,确认发送后的 8 秒启动等待计入总耗时、100ms 递增及终态冻结;仅历史恢复、live 为空的回合显示正确的 2.0 秒。真实 Provider 与原生客户端端到端尚未验证,不据本地通过宣称远程 CI 或发布通过。 diff --git a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md index e057bdd8e..173fe3116 100644 --- a/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md +++ b/docs/project-memory/plans/【实施计划】画布验收问题统一修复-2026-09-17.md @@ -1,5 +1,13 @@ # 【实施计划】画布验收问题统一修复 +## PR 补审返修(2026-09-18) + +用户确认修复:生成面板按 draftId 隔离且切换保留各草稿;失败重试保留失效参考身份并在提交前明确拒绝,不改变原付费请求;不同栏目整理意图分别排队;多选拖动预览和写回统一使用按下时起点。每项先加失败回归,再最小修复、独立复核与集成验证。共用 index.tsx 时限定不重叠区域,禁止整文件重写。卡片重设计、任务浮层和远程写入不在这次 fix 范围。 + +返修已完成:生成占位实例与草稿隔离、失效参考及原请求输入修改阻断、队尾同范围整理合并、场景预览与局部写回分别冻结同一按下时基准。生成/布局/计时合计 17 文件 213 项定向通过;appSurface 462 项通过、17 项跳过。音频失败后切换导致草稿丢失的集成回归也已修复并重跑通过,没有删断言或加任意等待。 + +独立交叉 review 无新增阻断;浏览器使用真实工作台和模拟原生接口确认 BGM/音效以及同类图片的不同占位输入隔离、切回恢复原输入。未发起真实付费生成、未运行真实 IPC 端到端;不同栏目排队整理的乐观中间态仍可能短暂回退,最终落盘范围与顺序正确,该既有表现未纳入本次返修。 + | 字段 | 值 | | --- | --- | | Milestone | docs/project-memory/plans/【里程碑】画布验收问题统一修复-2026-09-17.md | diff --git a/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md b/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md index fe4b35c88..1d119aeb2 100644 --- a/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md @@ -27,6 +27,8 @@ ## 验证与剩余风险 +补审返修已保留同身份真实发送时间,并为生命周期事件补 userItemId,使 live 为空时也能精确关联历史回合;独立 review 与新增恢复、迟到终态和取消回包回归通过。最终证据见对应实施计划,真实客户端验收项仍不勾选。 + - appSurface 与 reducer/回合投影三文件合计 493 项通过、17 项跳过;最终初始占位与计时边界补丁另跑 5 项通过。 - 隔离依赖配置下 AGC TypeScript、skill/config、ESLint、编码与文档索引检查通过;Rust all-targets locked offline check 通过。 - 原生实现者执行 direct_thread 35 项、direct_ 312 项(1 跳过)、codex_app_server 58 项(1 跳过)及系统工程提示定向用例通过,过滤集合有重叠,不能相加作为独立用例总数。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1370d0d83..39f3d777f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 生成草稿与异步展示边界必须按身份隔离 + +非模态生成浮层切换占位时按 draftId 分实例,卸载保留未提交/失败草稿,成功提交不再复活草稿;旧项目占位不存在时丢弃其保存回调。失败重试保留原请求输入和引用身份,引用失效不能静默过滤;修改已绑定输入须明确另起请求,不伪装成原请求重试。 + +聊天真实发送时间按相同用户 itemId 与正式条目合并,不能被启动应答后的观测时间覆盖。Thread Manager 生命周期事件透传已有 userItemId,使仅历史回读、live 为空的回合仍可精确补终态时间;不得按历史尾项或时间近似猜归属。布局整理仅可合并队尾同范围意图,不能越过中间手动写入;拖动预览和保存均冻结按下时的相应坐标基准。 + ## Phaser 与 CSS 双重居中导致游戏画面偏移 Phaser `Scale.FIT` 与 `autoCenter: CENTER_BOTH` 会给 canvas 计算定位外边距。若 canvas 的直接父容器同时使用 `display: grid; place-items: center` 或另一套 CSS 居中,浏览器再次定位带 margin 的元素,竖屏游戏会相对预览区域偏右。应只保留一个居中责任方:Phaser 居中时直接父容器使用尺寸明确的普通块布局;CSS 居中时设置 `autoCenter: NO_CENTER`。不禁止外围页面的 Grid/Flex 布局,不通过修改 AGC iframe 的固定偏移掩盖项目 CSS 问题。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index fe7949004..445251169 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,6 +4,12 @@ 开发 Agent 的系统工程提示与 `agc-web-game-development` Skill 明确约束同一 canvas 的居中只能由一方负责:Phaser `FIT + CENTER_BOTH` 配合尺寸明确的普通块级父容器,不叠加同一父容器的 Grid/Flex 居中、自动外边距或居中 transform;如由 CSS 居中则设置 Phaser `NO_CENTER`,外围页面的 Grid/Flex 不受此限制。布局修改后构建实际 dist,并在桌面、移动和 resize 下核对 canvas 对游戏父容器中心偏差不超过 1 CSS px、无溢出与意外滚动条。出现偏移先检查游戏项目的 CSS/scale,不修改 AGC 预览固定偏移掩盖问题;这些要求通过 Agent 指引执行,不新增运行时门禁或平行校验系统。 +## 画布验收返修边界 + +- 生成浮层的实例、输入、失败状态与请求身份必须按占位草稿身份隔离;切换到另一张占位时不能沿用上一张的工具类型、输入或 operation。切换未提交草稿仍须保留各自输入,回到原占位可继续编辑。 +- 失败任务重试必须保留原请求的完整参考资产 ID 集合。参考资产失效时明确提示并阻止旧请求提交,不得静默过滤后用改变的参考集恢复原 operation,也不得自动创建新的付费请求。 +- 显式整理的待写意图不能因切换栏目而覆盖其他栏目的排队范围。每个受影响范围与撤销步骤保持一致;拖动预览和最终写回使用同一份按下时冻结的起点快照,即使拖动期间布局收到异步更新也不跳位。 + ## 多选素材批量标签 - 画布与资源面板共用选中集合。选择至少两项同项目已登记素材后,从已有“编辑标签”入口或资源面板的“批量标签”动作打开同一标签编辑器的批量模式;入口遵循既有“常用操作 + 更多”收纳,不新增平行资源管理页。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 9f02873a5..adda7fa04 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -27,6 +27,8 @@ - 沿用 Thread Manager 的生命周期与条目身份,不引入第二套回合状态源、计时账本、远程 API 或数据库字段。前端仅保存原事件的展示时间边界;旧历史缺少完整边界时不声称知道精确总耗时或工具耗时,不为补齐计时启动模型请求。 - 必须覆盖:两组工具共用整轮起点、无新事件仍增长、工具完成后 LLM 继续、并行工具分别计时、完成/失败/终止冻结、同身份重放不改终态时间、切项目与卸载停止计时、缺失与倒序时间、0.0 / 59.9 / 60.0 秒边界。真实 Provider 验证与模拟事件的客户端验证分开报告。 - Direct 对话的初始消息占位仅在正式用户消息尚未进入当前显示链路时显示;正式条目或乐观用户条目出现后撤下占位,不在消息列表外额外保留一份。只控制占位是否显示,不按文本合并或删除用户真实重复发送的消息;分页已有更早历史时不把初始占位补到当前页。 +- 同一用户消息从本地发送转为正式条目时必须保留原发送时间,不能因去重丢掉该时间而改用启动应答后的观测时间。回合明确结束时,即使当前 live 集合为空,也应将终态边界关联到已知的本轮用户条目;不得仅按“历史最后一项”猜测或向无关旧回合补时间。 +- 生命周期事件可携带已有用户条目的 `userItemId`,用于把时间边界精确关联到同一条历史消息(不产生新的回合 ID 或持久化字段)。恢复时该关联随 Thread Manager 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。 ## 背景与现状(已核实)