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({
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts
index 9fbac5e64..6ef65017a 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts
@@ -1,5 +1,9 @@
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
-import type { ChatReference } from '../project-workspace/resourceReferences';
+import type { GameCreationAppAssetCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
+import {
+ type ChatReference,
+ resourceReferenceFromAsset,
+} from '../project-workspace/resourceReferences';
import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel';
/**
@@ -25,9 +29,8 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC = 4;
* 其它入口一律支持用户参考,包括 `icon-spec`(图标规范):它虽然产出权威规范图,但生成时同样
* 可以带参考图,上限与普通生成一致。
*/
-const RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS: readonly string[] = [
- 'art-spritesheet',
-];
+const RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS: readonly string[] =
+ ['art-spritesheet'];
export function resourceCanvasAssetGenerationAcceptsReferences(
action: ResourceCanvasAssetToolAction,
@@ -65,7 +68,9 @@ export function resourceCanvasAssetGenerationReferenceAssets(
): GameCreationAppAssetManifestEntry[] {
return assets.filter(
(asset) =>
- resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType) &&
+ resourceCanvasAssetGenerationReferenceMediaTypeSupported(
+ asset.mediaType,
+ ) &&
asset.localPath.trim().length > 0 &&
!asset.localPath.startsWith('.agent/'),
);
@@ -151,32 +156,153 @@ export function resourceCanvasAssetGenerationReferenceIssue({
references: readonly ChatReference[];
assets: readonly GameCreationAppAssetManifestEntry[];
}): string | null {
+ return (
+ resourceCanvasAssetGenerationReferenceProblems({ references, assets })[0]
+ ?.reason ?? null
+ );
+}
+
+/**
+ * 一条参考不能作为本次生成输入的原因。`resourceId` 与 `label` 原样保留提交时的身份,
+ * 面板据此把这条参考**显式**呈现出来。
+ */
+export type ResourceCanvasAssetGenerationReferenceProblem = {
+ resourceId: string;
+ label: string;
+ reason: string;
+};
+
+/**
+ * 逐条列出不能提交的参考(保留原 id 与展示名)。
+ *
+ * 与 `resourceCanvasAssetGenerationReferenceIssue` 同一份判据,只是不再只留第一条:面板要把
+ * 每一条失效参考连同原因显示出来,用户才知道该恢复哪一张,而不是看到一句泛泛的失败原因。
+ */
+export function resourceCanvasAssetGenerationReferenceProblems({
+ references,
+ assets,
+}: {
+ references: readonly ChatReference[];
+ assets: readonly GameCreationAppAssetManifestEntry[];
+}): ResourceCanvasAssetGenerationReferenceProblem[] {
+ const problems: ResourceCanvasAssetGenerationReferenceProblem[] = [];
for (const reference of references) {
if (reference.type !== 'resource') {
continue;
}
const resourceId = reference.resourceId.trim();
if (!resourceId) {
- return '参考图引用缺少资源身份,请重新选择后再提交';
+ problems.push({
+ resourceId: reference.resourceId,
+ label: reference.label,
+ reason: '参考图引用缺少资源身份,请重新选择后再提交',
+ });
+ continue;
}
const asset = assets.find((item) => item.id === resourceId);
if (!asset) {
- return `参考图「${reference.label}」已不在当前项目,请移除后再提交`;
+ problems.push({
+ resourceId,
+ label: reference.label,
+ reason: `参考图「${reference.label}」已不在当前项目,请移除后再提交`,
+ });
+ continue;
}
if (!asset.mediaType.startsWith('image/')) {
- return `参考图「${reference.label}」不是图片,不能作为生成参考`;
+ problems.push({
+ resourceId,
+ label: reference.label,
+ reason: `参考图「${reference.label}」不是图片,不能作为生成参考`,
+ });
+ continue;
}
if (
!resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType)
) {
- return `参考图「${reference.label}」是矢量图(${asset.mediaType}),暂不支持作为生成参考,请换栅格图片`;
+ problems.push({
+ resourceId,
+ label: reference.label,
+ reason: `参考图「${reference.label}」是矢量图(${asset.mediaType}),暂不支持作为生成参考,请换栅格图片`,
+ });
+ continue;
}
- if (
- !asset.localPath.trim() ||
- asset.localPath.startsWith('.agent/')
- ) {
- return `参考图「${reference.label}」没有本地文件,无法作为参考传递`;
+ if (!asset.localPath.trim() || asset.localPath.startsWith('.agent/')) {
+ problems.push({
+ resourceId,
+ label: reference.label,
+ reason: `参考图「${reference.label}」没有本地文件,无法作为参考传递`,
+ });
}
}
- return null;
+ return problems;
+}
+
+/** 提交时留给重试的参考展示名:id → 显示名,草稿重建后 `@显示名` 才与正文对得上。 */
+export function resourceCanvasAssetGenerationReferenceLabels(
+ references: readonly ChatReference[],
+): Record {
+ const labels: Record = {};
+ for (const reference of references) {
+ if (reference.type !== 'resource') {
+ continue;
+ }
+ const resourceId = reference.resourceId.trim();
+ if (!resourceId || labels[resourceId]) {
+ continue;
+ }
+ labels[resourceId] = reference.label;
+ }
+ return labels;
+}
+
+/**
+ * 原请求冻结的参考集合与当前草稿是否仍然是同一份(顺序敏感)。
+ *
+ * 重试面板用它做「输入有没有被改动」的判据:参考集合进的是账本请求正文(`referenceImageSrcs`),
+ * 恢复时逐项比对(见 Rust `resolve_platform_art_generation_references_at`)。删掉一张再提交就
+ * 不再是原请求的重试——可能被原生拒绝恢复、也可能成为一次不同的付费意图,而用户以为自己只是
+ * 在重试。集合不同就不许提交,由用户显式选择恢复原参考或另起新请求。
+ */
+export function resourceCanvasAssetGenerationReferenceIdsMatch(
+ bound: readonly string[],
+ current: readonly string[],
+): boolean {
+ return (
+ bound.length === current.length &&
+ bound.every((resourceId, index) => resourceId === current[index])
+ );
+}
+
+/**
+ * 用任务上冻结的参考身份重建重试草稿:**失效参考保留原 ID 与原名,不静默丢弃**。
+ *
+ * 丢弃看起来温和,实际是「用户以为带了那张参考、实际发了一次无参考的付费生成」,而原请求的
+ * 身份本来就绑定那份参考集合,改掉参考再重试会被原生拒绝。所以这里保留一条占位引用,
+ * 交给提交前的判据把原因说清楚,由用户自己决定恢复原参考还是另起一次新生成。
+ */
+export function resourceCanvasAssetGenerationRetryReferences({
+ referenceAssetIds,
+ referenceLabels,
+ assets,
+}: {
+ referenceAssetIds: readonly string[];
+ referenceLabels?: Readonly>;
+ assets: readonly GameCreationAppAssetManifestEntry[];
+}): ChatReference[] {
+ return referenceAssetIds.map((assetId) => {
+ const asset = assets.find((candidate) => candidate.id === assetId);
+ if (asset) {
+ return resourceReferenceFromAsset(asset, 'asset-picker');
+ }
+ return {
+ type: 'resource' as const,
+ resourceId: assetId,
+ kind: '',
+ mediaType: '',
+ label: referenceLabels?.[assetId]?.trim() || assetId,
+ category: 'unclassified' as GameCreationAppAssetCategory,
+ tags: [],
+ source: 'asset-picker' as const,
+ };
+ });
}
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts
index 2cd53e85b..57995d382 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts
@@ -57,6 +57,13 @@ export type ResourceCanvasAssetGenerationTask = {
* 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。
*/
referenceAssetIds: string[];
+ /**
+ * 这些参考**当时**的显示名(id → 显示名)。
+ *
+ * 只给失败重试重建草稿用:素材被删掉之后清单里已经查不到名字,而重试面板要能把失效的那一条
+ * 连名字一起显示出来(`@显示名` 也要与正文对得上)。账本里没有它,恢复出来的历史任务为空表。
+ */
+ referenceLabels: Record;
/**
* 这次的**入口栏目**:用户点工具时正在看的那个栏目。
*
@@ -174,6 +181,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
aspectRatio: string;
imageSize: string;
referenceAssetIds?: readonly string[];
+ referenceLabels?: Readonly>;
targetCategory?: ProjectResourceCanvasCategory | null;
outputPath: string | null;
projectId: string;
@@ -198,6 +206,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
.filter((assetId) => assetId.length > 0),
),
],
+ referenceLabels: { ...(input.referenceLabels ?? {}) },
targetCategory: input.targetCategory ?? null,
outputPath: input.outputPath,
projectId: input.projectId,
@@ -231,6 +240,7 @@ export function restoreResourceCanvasAssetGenerationTask(
aspectRatio: '',
imageSize: '',
referenceAssetIds: [],
+ referenceLabels: {},
targetCategory: null,
outputPath: null,
projectId: record.projectId,
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
index 20b5c7981..9f550aeca 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx
@@ -114,9 +114,7 @@ import {
dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget,
resolveActiveIterationVersion,
- type ResourceReference,
resourceReferenceCategoryLabel,
- resourceReferenceFromAsset,
} from '../../features/project-workspace/resourceReferences';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
import {
@@ -130,7 +128,11 @@ import {
type ResourceCanvasAssetGenerationQueue,
type ResourceCanvasAssetGenerationSettlement,
} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue';
-import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
+import {
+ resourceCanvasAssetGenerationReferenceIds,
+ resourceCanvasAssetGenerationReferenceLabels,
+ resourceCanvasAssetGenerationRetryReferences,
+} from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
import {
createResourceCanvasAssetGenerationTask,
type LocalProjectAssetGenerationTaskRecord,
@@ -1966,6 +1968,13 @@ export default function ProjectDevelopmentView({
draftId: string;
draft: ResourceCanvasAssetGenerationPanelDraft;
error: string;
+ /**
+ * 这份草稿是不是某条**已提交请求**的重试(失败收口后从任务账本还原)。
+ *
+ * 只有它成立时「原请求的身份绑定这份参考集合」才是事实,失效参考的处置说明才会带上这句;
+ * 用户自己收起浮层留下的草稿没有请求身份,不需要按重试口径解释。
+ */
+ retryOfRequest?: boolean;
} | null>(null);
const [
resourceAssetGenerationTasksPanelOpen,
@@ -2249,6 +2258,8 @@ export default function ProjectDevelopmentView({
startX: number;
startY: number;
}>;
+ /** 屏幕场景内的起点(包含“所有资源”的栏目偏移),与局部写回起点一起冻结。 */
+ previewStarts: ReadonlyMap;
resourceId: string;
section: ResourceCategory;
startClientX: number;
@@ -2264,6 +2275,7 @@ export default function ProjectDevelopmentView({
deltaX: number;
deltaY: number;
movedResourceIds: readonly string[];
+ previewStarts: ReadonlyMap;
} | null>(null);
const resourceDependencyOverlayRef =
useRef(null);
@@ -5712,6 +5724,15 @@ export default function ProjectDevelopmentView({
opensAllResources: resourceBookOpensAllResources,
activePageCategory,
}),
+ previewStarts: new Map(
+ resourceBookScenePlan
+ .flatMap((group) => group.cards)
+ .filter((card) => card.presentation === 'child')
+ .map((card) => [
+ card.resource.id,
+ { x: card.layout.x, y: card.layout.y },
+ ]),
+ ),
resourceId: resource.id,
section: resource.category,
startClientX: event.clientX,
@@ -5726,6 +5747,7 @@ export default function ProjectDevelopmentView({
canvasResources,
resourceCategoryScopeKey,
resourceBookOpensAllResources,
+ resourceBookScenePlan,
resourceReplacementPickMode,
selectedResourceIds,
visibleResourceIds,
@@ -5759,6 +5781,7 @@ export default function ProjectDevelopmentView({
deltaX: scaledDeltaX,
deltaY: scaledDeltaY,
movedResourceIds: drag.moves.map((move) => move.resourceId),
+ previewStarts: drag.previewStarts,
});
const primary = drag.moves[0];
resourceDependencyOverlayRef.current?.updateDragPreview({
@@ -7043,9 +7066,16 @@ export default function ProjectDevelopmentView({
)
? resourceCardDragPreview
: null;
- // 整张选择集按同一个位移走:每张卡各自加上同一份位移,相对位置保持原样。
- const previewX = dragPreview ? layout.x + dragPreview.deltaX : undefined;
- const previewY = dragPreview ? layout.y + dragPreview.deltaY : undefined;
+ // 异步布局重建不能改变拖动基准;与松手写回同样使用 pointerdown 的快照。
+ const previewStart = dragPreview?.previewStarts.get(resource.id);
+ const previewX =
+ dragPreview && previewStart
+ ? previewStart.x + dragPreview.deltaX
+ : undefined;
+ const previewY =
+ dragPreview && previewStart
+ ? previewStart.y + dragPreview.deltaY
+ : undefined;
return (
{
- const asset = manifest.assets.find(
- (candidate) => candidate.id === assetId,
- );
- return asset
- ? resourceReferenceFromAsset(asset, 'asset-picker')
- : null;
- })
- .filter(
- (reference): reference is ResourceReference =>
- reference !== null,
- ),
+ /*
+ 参考图按资产 ID 还原成引用。**已经不在清单里的那些原样保留**(用任务上冻结的显示名):
+ 静默过滤等于把「带这张参考」变成「没有参考」的一次付费生成,而原请求的身份本来就绑定
+ 那份参考集合,改掉参考再提交会被原生拒绝;所以缺口必须交给提交前的判据显式报出来,
+ 由用户决定恢复原参考还是另起一次新生成。
+ */
+ references: resourceCanvasAssetGenerationRetryReferences({
+ referenceAssetIds: retryTask.referenceAssetIds,
+ referenceLabels: retryTask.referenceLabels,
+ assets: manifest.assets,
+ }),
},
error: placeholder.error ?? '生成素材失败',
+ retryOfRequest: true,
});
} else {
// 用户自己收起过浮层:接着编辑同一份草稿,而不是回到空表单。
@@ -9582,10 +9614,35 @@ export default function ProjectDevelopmentView({
'audio' &&
resourceGenerationPanelStyle ? (
{
+ /*
+ 切项目会让旧浮层卸载,而卸载回调读到的宿主闭包已经属于**新**项目:
+ 旧 draftId 绝不能写进新项目的草稿槽。占位是当前项目的会话状态,
+ 占位已不在当前集合就说明这次清理属于上一个项目,直接丢弃。
+ */
+ if (
+ !resourceGenerationPlaceholdersRef.current.placeholderByDraftId(
+ resourceGenerationDraft.draftId,
+ )
+ ) {
+ return;
+ }
+ resourceGenerationDraftRef.current.set(
+ resourceGenerationDraft.draftId,
+ draft,
+ );
+ }}
// 用户收起过浮层就接着编辑同一份草稿(不是空表单)。
initialDraft={
resourceGenerationDraftRef.current.get(
@@ -9622,8 +9679,15 @@ export default function ProjectDevelopmentView({
'asset' &&
resourceGenerationPanelStyle ? (
{
+ // 同音频浮层:切项目后卸载回调不得把旧占位的草稿写进新项目。
+ if (
+ !resourceGenerationPlaceholdersRef.current.placeholderByDraftId(
+ resourceAssetGenerationPanel.draftId,
+ )
+ ) {
+ return;
+ }
+ resourceAssetGenerationDraftRef.current.set(
+ resourceAssetGenerationPanel.draftId,
+ draft,
+ );
+ }}
// 参考选择的候选集来自当前项目 manifest,收口到已登记图片;
// 与快速编辑同一份 `@` 链路。
assets={manifest.assets}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
index 074fda6f3..e028ca312 100644
--- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
+++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts
@@ -124,6 +124,54 @@ function automaticPositionPolicy(
const MANUAL_WRITE_AUTOMATIC_POSITION_POLICY: AutomaticPositionPolicy =
'preserve';
+/**
+ * 一次「整理画布」的目标范围键:`null` 是「所有资源」页=全部栏目,否则是有序栏目集合。
+ *
+ * 队列按这个键判断"是不是同一次整理",而不是按 `sections` 的数组身份——调用方每次渲染都会
+ * 传一份新数组。**不同范围必须是两笔独立意图**:栏目 A 与栏目 B 是两次用户动作,各对应一次
+ * 撤销、各一笔 CAS;用后一次覆盖前一次会把前一栏的整理从落盘里抹掉(画面上它已经被乐观视图
+ * 重排过,磁盘上却是"只整理了后一栏")。同范围才就地合并,合并时只刷新签名与重试计数,
+ * 保持它在队列里的位置,顺序语义不变。
+ */
+function organizeSectionsKey(
+ sections: readonly ProjectResourceCanvasCategory[] | null,
+) {
+ return sections === null
+ ? 'all'
+ : JSON.stringify([...new Set(sections)].sort());
+}
+
+/**
+ * 这一按可以就地合并的那一笔待处理整理:**必须在队尾**,且作用范围相同。
+ *
+ * "只管队尾"不是省事,是本条语义的必需:队列是 FIFO,合并进更早的同范围整理,这一按就落到
+ * 它**之后**排进来、还没落盘的写入之前——`整理A → 手动移动A → 再整理A` 会复用第一次那个槽位,
+ * 最终落盘的是中间那次手动坐标,用户最后按下的整理被静默吞掉。落在队尾时合并没有这个风险
+ * (后面没有更新的意图),所以连续按同一栏仍然只写一笔 CAS。
+ */
+function queuedOrganizeIntentAtTail(
+ queue: readonly LayoutWriteIntent[],
+ activeIntent: LayoutWriteIntent | null,
+ scopeEpoch: number,
+ sectionsKey: string,
+): ResourceLayoutWriteIntent | null {
+ const tail = queue[queue.length - 1];
+ if (!tail || tail === activeIntent) {
+ return null;
+ }
+ if (tail.kind !== 'resources') {
+ return null;
+ }
+ if (tail.scopeEpoch !== scopeEpoch) {
+ return null;
+ }
+ const rederive = tail.rederive;
+ if (!rederive || rederive.kind !== 'organize') {
+ return null;
+ }
+ return organizeSectionsKey(rederive.sections) === sectionsKey ? tail : null;
+}
+
function createScopeKey(
projectPath: string,
projectId: string,
@@ -676,10 +724,7 @@ export function useProjectResourceCanvasLayout({
*/
const manualCandidate =
intent.kind === 'manual'
- ? applyResourceCanvasPositionWrites(
- reconciled.layout,
- intent.positions,
- )
+ ? applyResourceCanvasPositionWrites(reconciled.layout, intent.positions)
: null;
const manualWritesAnything =
manualCandidate !== null &&
@@ -1107,10 +1152,7 @@ export function useProjectResourceCanvasLayout({
section: ProjectResourceCanvasCategory,
x: number,
y: number,
- ) =>
- commitPositions([
- { resourceId, section, x, y, manuallyPlaced: true },
- ]),
+ ) => commitPositions([{ resourceId, section, x, y, manuallyPlaced: true }]),
[commitPositions],
);
@@ -1165,6 +1207,9 @@ export function useProjectResourceCanvasLayout({
* 继续由既有 `notice` / `saving` 承担。历史快照与撤销由调用方(工作台)在按下时先记一笔,
* 本 hook 不新增业务状态。
*
+ * 排队粒度是**作用范围**:不同 `sections` 各占一个队列槽、按按压顺序先后落盘(栏目 A 的
+ * 整理不会被随后按下的栏目 B 改写掉),同一个 `sections` 才就地合并成一笔。
+ *
* 返回 `false` 表示这一按在画面上什么都不会变(已经整齐、也没有需要转成自动的手动标记):
* 既不排队写、也不给调用方记历史的理由——否则"连按两下"会压进一条什么都不会做的撤销点,
* 用户按一次撤销看起来毫无反应。
@@ -1213,12 +1258,16 @@ export function useProjectResourceCanvasLayout({
if (!rebuildOptimisticLayout(scope.epoch, 'rederive', sections)) {
return false;
}
- const queued = writeQueueRef.current.find(
- (intent): intent is ResourceLayoutWriteIntent =>
- intent.kind === 'resources' &&
- intent.scopeEpoch === scope.epoch &&
- intent.rederive?.kind === 'organize' &&
- intent !== activeWriteIntentRef.current,
+ /**
+ * 只在**队尾且同作用范围**时才就地合并(判据与理由见 `queuedOrganizeIntentAtTail`):
+ * 不同栏目各占一个队列槽、按按压顺序先后落盘,谁也不覆盖谁;命中时只刷新签名与重试
+ * 计数,不重排队列,先按下的那一笔仍然先落盘。
+ */
+ const queued = queuedOrganizeIntentAtTail(
+ writeQueueRef.current,
+ activeWriteIntentRef.current,
+ scope.epoch,
+ organizeSectionsKey(sections),
);
if (queued) {
queued.resourceSignature = resourceSignatureRef.current;
@@ -1289,7 +1338,8 @@ function mergeResourceCanvasPositionWrites(
for (const write of writes) {
const index = target.findIndex(
(entry) =>
- entry.resourceId === write.resourceId && entry.section === write.section,
+ entry.resourceId === write.resourceId &&
+ entry.section === write.section,
);
if (index >= 0) {
target[index] = write;
diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
index 46de8b4e0..6cf2fe11a 100644
--- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
+++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
+import type {
+ DirectChatEntry,
+ DirectThreadChatState,
+} from '../src/features/project-workspace/directThreadChat';
import {
emptyDirectThreadChatState,
finishDirectThreadTurn,
@@ -17,6 +21,14 @@ function event(
return partial as DirectThreadEvent;
}
+/** 生命周期事件上的 canonical user identity(原生 `userItemId`)。 */
+function withUserItemId(
+ lifecycle: DirectThreadEvent,
+ userItemId: string,
+): DirectThreadEvent {
+ return { ...lifecycle, userItemId };
+}
+
/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */
function toolStarted(
overrides: Partial<
@@ -372,7 +384,11 @@ describe('DirectProject 聊天 reducer', () => {
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-user',
events: [
- // 运行态里只有过程条目:开口条目已经在历史里。
+ // 原生在生命周期锚点上带出本轮 canonical user identity,开口条目已经在历史里。
+ withUserItemId(
+ event({ type: 'turn.started', at: 1_000_000 }),
+ 'msg-user',
+ ),
event({
type: 'item.completed',
at: 1_000_700,
@@ -389,6 +405,259 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe(
1_000_900,
);
+ expect(selectDirectChatEntries(bootstrapped)[0]?.turnStartedAt).toBe(
+ 1_000_000,
+ );
+ });
+
+ it('没有 userItemId 的生命周期事件保持顺序语义,但不猜历史归属', () => {
+ const bootstrapped = resolveDirectThreadBootstrap(
+ mergeDirectHistoryItems(emptyDirectThreadChatState(), [
+ messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
+ ]),
+ {
+ subscriptionId: 'sub-1',
+ lastCompletedItemId: 'msg-user',
+ events: [
+ // 旧原生事件不带身份:运行态条目照常收口,历史里那条不按尾巴猜。
+ event({
+ type: 'item.completed',
+ at: 1_000_700,
+ item: toolOutput(),
+ }),
+ event({
+ type: 'turn.completed',
+ status: 'completed',
+ at: 1_000_900,
+ }),
+ ],
+ },
+ );
+ const entries = selectDirectChatEntries(bootstrapped);
+ expect(entries).toHaveLength(2);
+ expect(entries[0]?.itemId).toBe('msg-user');
+ expect(entries[0]?.turnEndedAt).toBeUndefined();
+ // 运行态那条照常拿到边界(顺序语义不变)。
+ expect(entries[1]?.turnEndedAt).toBe(1_000_900);
+ });
+
+ it('本轮开口条目已在历史、运行态为空时,收口仍按身份把边界盖在它身上', () => {
+ const userItem = messageItem({
+ itemId: 'direct-codex:turn-1:user',
+ role: 'user',
+ text: '做一个拼图游戏',
+ });
+ const running = reduceDirectThreadEvents(
+ mergeDirectHistoryItems(emptyDirectThreadChatState(), [userItem]),
+ [
+ // 原生在生命周期事件上带出本轮 canonical user identity;这一轮没有任何运行态
+ // 条目,开口条目只存在于历史里。
+ withUserItemId(
+ event({ type: 'turn.started', at: 1_000_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ],
+ );
+ expect(running.live).toHaveLength(0);
+
+ const done = reduceDirectThreadEvents(running, [
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ]);
+ // 边界落在本轮这条用户条目上:既不丢终态时间,也没有第二套回合身份。
+ expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
+ expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
+ });
+
+ it('终态先于历史切片到达时,回读到开口条目仍补上已冻结的边界', () => {
+ const started = resolveDirectThreadBootstrap(
+ emptyDirectThreadChatState(),
+ {
+ subscriptionId: 'sub-1',
+ lastCompletedItemId: null,
+ events: [
+ withUserItemId(
+ event({ type: 'turn.started', at: 1_000_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ],
+ },
+ );
+ // 终态先到:这一刻历史里还没有那条用户条目。
+ const finished = reduceDirectThreadEvents(started, [
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ]);
+ expect(finished.turnEndedAt).toBe(1_000_900);
+ expect(finished.history).toHaveLength(0);
+
+ const hydrated = mergeDirectHistoryItems(finished, [
+ messageItem({
+ itemId: 'direct-codex:turn-1:user',
+ role: 'user',
+ text: '做一个拼图游戏',
+ at: 999_000,
+ }),
+ ]);
+ expect(hydrated.history[0]?.turnStartedAt).toBe(1_000_000);
+ expect(hydrated.history[0]?.turnEndedAt).toBe(1_000_900);
+ });
+
+ it('上一轮迟到的终态按身份被拒,不关掉正在跑的这一轮', () => {
+ const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
+ withUserItemId(
+ event({ type: 'turn.started', at: 2_000_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ event({ type: 'item.started', at: 2_000_100, item: toolStarted() }),
+ ]);
+
+ const late = reduceDirectThreadEvents(running, [
+ // 上一轮的终态(身份不同):哪怕时间戳更早,也不允许收口正在跑的这一轮。
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
+ 'direct-codex:turn-0:user',
+ ),
+ ]);
+ expect(late.turnRunning).toBe(true);
+ expect(late.turnEndedAt).toBe(0);
+ expect(late.live).toHaveLength(1);
+ });
+
+ it('同一轮的重复终态不抬高已冻结的终点', () => {
+ const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
+ withUserItemId(
+ event({ type: 'turn.started', at: 1_000_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ]);
+ const replayed = reduceDirectThreadEvents(done, [
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'completed', at: 9_900_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ]);
+ expect(replayed.turnEndedAt).toBe(1_000_900);
+ expect(replayed.live).toHaveLength(0);
+ });
+
+ it('本轮自己没有任何条目时,不把边界盖到上一轮已经收口的开口条目上', () => {
+ const previousOpener: DirectChatEntry = {
+ itemId: 'direct-codex:turn-0:user',
+ kind: 'message',
+ role: 'user',
+ text: '上一轮的问题',
+ at: 900_000,
+ turnStartedAt: 910_000,
+ turnEndedAt: 950_000,
+ };
+ const previous: DirectThreadChatState = {
+ ...emptyDirectThreadChatState(),
+ history: [previousOpener],
+ };
+ const done = reduceDirectThreadEvents(previous, [
+ withUserItemId(
+ event({ type: 'turn.started', at: 2_000_000 }),
+ 'direct-codex:turn-2:user',
+ ),
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'aborted', at: 2_000_400 }),
+ 'direct-codex:turn-2:user',
+ ),
+ ]);
+ expect(done.history[0]?.turnStartedAt).toBe(910_000);
+ expect(done.history[0]?.turnEndedAt).toBe(950_000);
+ });
+
+ /**
+ * 本轮开口条目只由 history slice 回读、运行态全程为空:身份只能来自原生生命周期事件上
+ * 带的 canonical user identity,不能按历史尾或时间戳猜。
+ */
+ it('只有生命周期锚点 + 历史回读的开口条目时,按原生身份精准恢复用时', () => {
+ const running = resolveDirectThreadBootstrap(
+ emptyDirectThreadChatState(),
+ {
+ subscriptionId: 'sub-1',
+ lastCompletedItemId: null,
+ events: [
+ withUserItemId(
+ event({ type: 'turn.started', at: 1_000_000 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ],
+ },
+ );
+ const withHistory = mergeDirectHistoryItems(running, [
+ messageItem({
+ itemId: 'direct-codex:turn-1:user',
+ role: 'user',
+ text: '做一个拼图游戏',
+ at: 999_000,
+ }),
+ ]);
+ expect(withHistory.live).toHaveLength(0);
+
+ const done = reduceDirectThreadEvents(withHistory, [
+ withUserItemId(
+ event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
+ 'direct-codex:turn-1:user',
+ ),
+ ]);
+ expect(done.turnRunning).toBe(false);
+ expect(done.turnEndedAt).toBe(1_000_900);
+ // 身份来自原生事件,历史里那条开口条目因此拿到同一组边界。
+ expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
+ expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
+ });
+
+ it('收口只填空,不抬高条目上已经冻结的终点', () => {
+ // 规则本身:已经写上的边界先到先用,重复 / 迟到的收口不得抬高它。
+ const frozenOpener: DirectChatEntry = {
+ itemId: 'direct-codex:turn-1:user',
+ kind: 'message',
+ role: 'user',
+ text: '做一个拼图游戏',
+ at: 999_000,
+ turnStartedAt: 1_000_000,
+ turnEndedAt: 1_000_900,
+ };
+ const state: DirectThreadChatState = {
+ ...emptyDirectThreadChatState(),
+ history: [frozenOpener],
+ turnUserItemId: 'direct-codex:turn-1:user',
+ live: [
+ {
+ itemId: 'call-late',
+ kind: 'tool',
+ role: null,
+ text: null,
+ at: 0,
+ toolCall: {
+ schemaVersion: 'agc-tool-call.v1',
+ id: 'call-late',
+ kind: 'command',
+ title: '执行命令',
+ summary: 'npm run build',
+ status: 'completed',
+ detail: { command: 'npm run build' },
+ startedAt: 1_000_100,
+ updatedAt: 1_000_700,
+ },
+ },
+ ],
+ turnStartedAt: 1_000_000,
+ };
+ const done = finishDirectThreadTurn(state, 9_900_000);
+ expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
+ expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
});
it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => {
diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
index 054ecae37..f8ad2981b 100644
--- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
+++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts
@@ -245,6 +245,30 @@ describe('DirectProject 聊天分区', () => {
expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050);
});
+ it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => {
+ const sentAt = 1_800_000_000_000;
+ // 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。
+ const ackAt = sentAt + 1_200;
+ const turns = buildDirectChatTurns({
+ entries: [
+ userEntry('direct-codex:turn-1:user', ackAt),
+ liveToolEntry('t1', sentAt + 400, sentAt + 900),
+ ],
+ // 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。
+ localMessages: [
+ {
+ role: 'user' as const,
+ text: '问题 direct-codex:turn-1:user',
+ messageId: 'direct-codex:turn-1:user',
+ updatedAt: sentAt,
+ },
+ ],
+ });
+ // 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。
+ expect(turns[0]?.startedAt).toBe(sentAt);
+ expect(turns[0]?.users[0]).toMatchObject({ at: sentAt });
+ });
+
it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '正文')],
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx
index a2371b674..74586b0ed 100644
--- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx
+++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
+import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
@@ -17,7 +18,9 @@ import {
render,
screen,
waitFor,
+ within,
} from './appSurface/harness';
+import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
/**
* 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。
@@ -88,119 +91,160 @@ function installHostTauri(options: {
| { ok: true; assetId: string; hold?: boolean }
| { ok: false; message: string }
>;
+ /**
+ * 图片类入口(`start_local_project_asset_generation`)的账本收口方式。
+ *
+ * 不传时这条命令不被这些用例触发(音频入口走 `derive_local_project_resource`);
+ * `'failed'` 让队列在第一次轮询就看到一条失败记录,用来观察失败态重试。
+ */
+ assetGenerationRecord?: 'failed';
}) {
const layoutWrites: LayoutWrite[] = [];
const deriveCalls: Array> = [];
+ const assetGenerationStarts: Array> = [];
const unexpectedCommands: string[] = [];
let revision = 0;
let releaseHeldDerive: (() => void) | null = null;
const positions: ProjectResourceCanvasPosition[] = [];
- const invoke = vi.fn(async (command: string, args?: Record) => {
- if (command === 'get_local_game_project_revision') {
- return { revision: 1 };
- }
- if (command === 'read_local_project_resource_graph') {
- return {
- nodes: [],
- taskFlowIds: [],
- producerAssignments: [],
- dependencyDepths: options.assets.map((asset) => ({
- resourceId: `asset:${asset.id}`,
- dependencyDepth: 0,
- })),
- unresolvedReferenceResourceIds: [],
- cyclicResourceIds: [],
- cyclicTaskIds: [],
- producerMappingTruncated: false,
- };
- }
- if (command === 'read_local_project_resource_canvas_layout') {
- return {
- schemaVersion: 'game-creator-resource-layout.v1',
- projectId: PROJECT_ID,
- mode: String(args?.mode ?? ''),
- revision,
- positions: structuredClone(positions),
- updatedAt: 0,
- };
- }
- if (command === 'update_local_project_resource_canvas_layout') {
- const next = structuredClone(
- (args?.positions ?? []) as ProjectResourceCanvasPosition[],
- );
- positions.length = 0;
- positions.push(...next);
- revision += 1;
- layoutWrites.push({
- projectPath: String(args?.projectPath ?? ''),
- mode: String(args?.mode ?? ''),
- positions: next,
- });
- return {
- status: 'updated',
- layout: {
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ if (command === 'get_local_game_project_revision') {
+ return { revision: 1 };
+ }
+ if (command === 'read_local_project_resource_graph') {
+ return {
+ nodes: [],
+ taskFlowIds: [],
+ producerAssignments: [],
+ dependencyDepths: options.assets.map((asset) => ({
+ resourceId: `asset:${asset.id}`,
+ dependencyDepth: 0,
+ })),
+ unresolvedReferenceResourceIds: [],
+ cyclicResourceIds: [],
+ cyclicTaskIds: [],
+ producerMappingTruncated: false,
+ };
+ }
+ if (command === 'read_local_project_resource_canvas_layout') {
+ return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: String(args?.mode ?? ''),
revision,
+ positions: structuredClone(positions),
+ updatedAt: 0,
+ };
+ }
+ if (command === 'update_local_project_resource_canvas_layout') {
+ const next = structuredClone(
+ (args?.positions ?? []) as ProjectResourceCanvasPosition[],
+ );
+ positions.length = 0;
+ positions.push(...next);
+ revision += 1;
+ layoutWrites.push({
+ projectPath: String(args?.projectPath ?? ''),
+ mode: String(args?.mode ?? ''),
positions: next,
- updatedAt: revision,
- },
- };
- }
- if (command === 'derive_local_project_resource') {
- const input = args?.input as Record;
- deriveCalls.push(input);
- const planned = options.deriveResults.shift();
- if (!planned || !planned.ok) {
- throw new Error(planned?.message ?? '测试:没有预置生成结果');
- }
- // `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。
- if (planned.hold) {
- await new Promise((resolve) => {
- releaseHeldDerive = resolve;
});
+ return {
+ status: 'updated',
+ layout: {
+ schemaVersion: 'game-creator-resource-layout.v1',
+ projectId: PROJECT_ID,
+ mode: String(args?.mode ?? ''),
+ revision,
+ positions: next,
+ updatedAt: revision,
+ },
+ };
}
- const asset = bgmAsset(planned.assetId);
- return {
- asset,
- manifest: manifestFor(PROJECT_ID, [...options.assets, asset]),
- committedProjectRevision: 2,
- operationId: input.operationId,
- };
- }
- if (command === 'list_pending_local_project_resource_edits') {
- return [];
- }
- if (command === 'list_local_project_asset_generations') {
- return [];
- }
- if (command === 'read_local_project_image_preview') {
- return {
- path: String(args?.relativePath ?? ''),
- mediaType: 'image/png',
- byteLen: 1,
- dataUrl: 'data:image/png;base64,AA==',
- };
- }
- if (command === 'read_local_project_text_preview') {
- return {
- path: String(args?.relativePath ?? ''),
- mediaType: 'text/markdown',
- byteLen: 2,
- content: '#',
- };
- }
- unexpectedCommands.push(command);
- return undefined;
- });
+ if (command === 'derive_local_project_resource') {
+ const input = args?.input as Record;
+ deriveCalls.push(input);
+ const planned = options.deriveResults.shift();
+ if (!planned || !planned.ok) {
+ throw new Error(planned?.message ?? '测试:没有预置生成结果');
+ }
+ // `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。
+ if (planned.hold) {
+ await new Promise((resolve) => {
+ releaseHeldDerive = resolve;
+ });
+ }
+ const asset = bgmAsset(planned.assetId);
+ return {
+ asset,
+ manifest: manifestFor(PROJECT_ID, [...options.assets, asset]),
+ committedProjectRevision: 2,
+ operationId: input.operationId,
+ };
+ }
+ if (command === 'list_pending_local_project_resource_edits') {
+ return [];
+ }
+ if (command === 'start_local_project_asset_generation') {
+ const startArgs = args ?? {};
+ assetGenerationStarts.push(startArgs);
+ return {
+ taskId: String(startArgs.taskId ?? ''),
+ projectId: PROJECT_ID,
+ kind: String(startArgs.kind ?? ''),
+ assetName: String(startArgs.assetName ?? ''),
+ referenceAssetIds: Array.isArray(startArgs.referenceAssetIds)
+ ? startArgs.referenceAssetIds
+ : [],
+ status: 'queued',
+ phaseDetail: '已受理',
+ startedAtMillis: 1,
+ finishedAtMillis: null,
+ assetId: null,
+ error: null,
+ };
+ }
+ if (command === 'list_local_project_asset_generations') {
+ return assetGenerationStarts.map((started) => ({
+ ...started,
+ status: options.assetGenerationRecord ?? 'queued',
+ phaseDetail:
+ options.assetGenerationRecord === 'failed' ? '生成失败' : '已受理',
+ finishedAtMillis:
+ options.assetGenerationRecord === 'failed' ? 2 : null,
+ error:
+ options.assetGenerationRecord === 'failed'
+ ? '测试:生成失败'
+ : null,
+ }));
+ }
+ if (command === 'read_local_project_image_preview') {
+ return {
+ path: String(args?.relativePath ?? ''),
+ mediaType: 'image/png',
+ byteLen: 1,
+ dataUrl: 'data:image/png;base64,AA==',
+ };
+ }
+ if (command === 'read_local_project_text_preview') {
+ return {
+ path: String(args?.relativePath ?? ''),
+ mediaType: 'text/markdown',
+ byteLen: 2,
+ content: '#',
+ };
+ }
+ unexpectedCommands.push(command);
+ return undefined;
+ },
+ );
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return {
invoke,
layoutWrites,
deriveCalls,
+ assetGenerationStarts,
unexpectedCommands,
releaseHeldDerive: () => releaseHeldDerive?.(),
};
@@ -226,6 +270,57 @@ function HostWorkbench({ assets }: { assets: AssetFixture[] }) {
);
}
+/**
+ * 带「从清单里删掉某个素材」按钮的宿主:失效参考的判据必须在**素材真的不在清单里**之后成立,
+ * 所以用例要能在渲染过程中推进一次 manifest(与用户在素材管理里删除同一条路径)。
+ */
+function HostWorkbenchWithReferenceRemoval({
+ assets,
+ referenceAssetId,
+}: {
+ assets: AssetFixture[];
+ referenceAssetId: string;
+}) {
+ const [manifest, setManifest] = React.useState(() =>
+ manifestFor(PROJECT_ID, assets),
+ );
+ return (
+ <>
+
+
+ Supervisor }
+ onHomeOpen={() => undefined}
+ onProjectsOpen={() => undefined}
+ onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
+ />
+ >
+ );
+}
+
async function settle() {
await act(async () => {
await Promise.resolve();
@@ -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 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。
## 背景与现状(已核实)