diff --git a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx index e690a313b..fbb5f8650 100644 --- a/apps/admin-web/src/pages/AdminErrorReportsPage.tsx +++ b/apps/admin-web/src/pages/AdminErrorReportsPage.tsx @@ -88,10 +88,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { setStatus(''); try { const updated = await updateAdminErrorReport(token, selected.batchId, { - status: nextStatus, - note: selected.note, - }); - setSelected((current) => (current ? { ...current, ...updated } : current)); + status: nextStatus, + note: selected.note, + }); + setSelected((current) => + current ? { ...current, ...updated } : current, + ); await loadRef.current(); } catch (error) { if (isAdminApiError(error) && error.status === 401) onUnauthorized(); @@ -244,7 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) { value={selected.note ?? ''} onChange={(event) => setSelected((current) => - current ? { ...current, note: event.target.value } : current, + current + ? { ...current, note: event.target.value } + : current, ) } maxLength={2000} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 13c863112..84504d2d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1943,17 +1943,23 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, request: &LlmRunRequest, llm: &GameCreatorLlmConfig, - ) -> Result { + ) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> { let key = CodexNodeThreadKey::from(snapshot); let mut threads = self.inner.threads.lock().await; + // The caller holds `turn_gate` while invoking this method. Returning + // the creation bit from the same threads lock keeps the replay + // decision atomic with thread reuse/creation. if let Some(entry) = threads.get_mut(&key) { entry.active_uses = entry.active_uses.saturating_add(1); entry.last_used = next_game_creator_codex_app_server_usage_tick(); - return Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id: entry.thread_id.clone(), - }); + return Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }, + false, + )); } if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { let evict_key = threads @@ -2015,11 +2021,14 @@ impl CodexAppServerConnection { active_uses: 1, }, ); - Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id, - }) + Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }, + true, + )) } async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { @@ -2085,11 +2094,60 @@ impl CodexAppServerConnection { mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, + ) -> Result { + self.run_turn_with_direct_observer_and_history( + snapshot, + llm, + request, + None, + None, + on_agent_message_delta, + direct_observer, + audit, + ) + .await + } + + async fn run_turn_with_direct_observer_and_history( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + direct_history_root: Option<&std::path::Path>, + direct_client_turn_id: Option<&str>, + mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; - let thread_lease = self.thread_for(snapshot, &request, llm).await?; + let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; self.wait_for_initial_client_mcp_startup().await; let thread_id = thread_lease.thread_id.clone(); + let mut request = request; + if thread_created && self.inner.workspace_mode.uses_direct_conversation() { + // DirectProject owns the append-only project history in AGC. The + // replay builder derives a bounded prompt without mutating that + // durable fact source, so a new ephemeral thread can recover the + // newest contiguous context within the model budget. + let current_prompt = direct_codex_current_user_prompt(&request).to_string(); + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + let history_prompt = build_direct_codex_history_prompt( + history_root, + direct_client_turn_id.unwrap_or("__none__"), + ¤t_prompt, + &request, + llm, + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + if let Some(message) = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + { + message.content = history_prompt; + } + } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) } else { @@ -3020,6 +3078,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( user_prompt, None, None, + None, ) .await } @@ -3034,6 +3093,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( root, system_prompt, user_prompt, + None, Some(observer), None, ) @@ -3090,6 +3150,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, + client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { @@ -3129,11 +3190,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; - let request = LlmRunRequest::single_turn(system_prompt, user_prompt) - .with_api_kind(api_kind) - .with_model(config.llm.model.clone()) - .with_request_timeout_ms(config.llm.request_timeout_ms) - .with_max_output_tokens(16_000); let connection = CodexAppServerConnection::acquire_at_workspace( &snapshot, &config.llm, @@ -3142,13 +3198,176 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|error| error.to_string())?; + let request = LlmRunRequest::single_turn(system_prompt, user_prompt) + .with_api_kind(api_kind) + .with_model(config.llm.model.clone()) + .with_request_timeout_ms(config.llm.request_timeout_ms) + .with_max_output_tokens(16_000); connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) + .run_turn_with_direct_observer_and_history( + &snapshot, + &config.llm, + request, + Some(&codex_root), + client_turn_id, + None, + observer, + audit, + ) .await .map(|value| value.text) .map_err(|error| error.to_string()) } +/// Builds the prompt used when a new DirectProject thread needs replay. +/// +/// The JSONL history remains immutable; only the derived prompt is bounded. +/// When the full request exceeds the replay target, the oldest contiguous +/// records are omitted and a plain `system:` marker is prepended. +pub(crate) fn build_direct_codex_history_prompt( + root: &std::path::Path, + client_turn_id: &str, + current_prompt: &str, + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, +) -> Result { + let conversation = read_local_conversation_for_session_at(root, None, None)?; + let current_message_id = format!("direct-codex:{client_turn_id}:user"); + // The deliberately simple role-prefix format is part of the Direct + // replay contract. Do not introduce an envelope or implicit escaping + // here without updating the persisted-history compatibility decision. + let lines = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + let current_line = format!("user: {}", current_prompt.trim()); + let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); + let target_budget = direct_codex_replay_target_budget(llm, base_request)?; + if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { + return Ok(full_prompt); + } + + // The persisted project conversation is immutable. We only derive a + // bounded prompt for this replay, keeping the newest contiguous records. + let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; + let mut selected_reversed = Vec::new(); + for line in lines.iter().rev() { + let mut candidate_reversed = selected_reversed.clone(); + candidate_reversed.push(line.as_str()); + let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); + let candidate_prompt = format_direct_codex_replay_prompt( + &candidate_lines, + ¤t_line, + Some(omission_marker), + ); + if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { + selected_reversed.push(line.as_str()); + } else { + break; + } + } + + let selected_lines = selected_reversed.iter().rev().copied().collect::>(); + let marked_prompt = + format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); + if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { + return Ok(marked_prompt); + } + + // If the marker itself would push the request over the target, preserve + // the current user request and omit only the marker. + let current_only_prompt = + format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); + if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { + return Ok(current_only_prompt); + } + direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) +} + +const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = + "system: Earlier conversation history was omitted due to context budget."; + +fn format_direct_codex_replay_prompt( + history_lines: &[impl AsRef], + current_line: &str, + omission_marker: Option<&str>, +) -> String { + let mut lines = Vec::with_capacity(history_lines.len() + 2); + if let Some(marker) = omission_marker { + lines.push(marker.to_string()); + } + lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); + lines.push(current_line.to_string()); + lines.join("\n") +} + +fn direct_codex_replay_target_budget( + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + const SAFETY_MARGIN_TOKENS: u64 = 4_096; + let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); + let hard_budget = llm + .context_window_tokens + .checked_sub(max_output_tokens) + .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) + .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; + Ok(llm.auto_compact_token_limit.min(hard_budget)) +} + +fn direct_codex_replay_estimate( + base_request: &LlmRunRequest, + prompt: &str, +) -> Result { + let mut request = base_request.clone(); + let user = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; + user.content = prompt.to_string(); + Ok(request) +} + +fn direct_codex_replay_prompt_fits( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, + target_budget: u64, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + if estimated > target_budget { + return Ok(false); + } + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(true) +} + +fn direct_codex_replay_validate_context_budget( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(prompt.to_string()) +} + /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3234,6 +3453,119 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + #[test] + fn direct_history_prompt_replays_all_project_messages_in_order() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "history-project", "history").expect("init"); + for (role, content, message_id) in [ + ("user", "hello", "direct-codex:old:user"), + ( + "assistant", + "partial\nunexpected interrupt happened here", + "partial-id", + ), + ("tool", "file-read result", "tool-id"), + ("user", "stored raw request", "direct-codex:new-turn:user"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content: content.to_string(), + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &test_llm(), + ) + .expect("build prompt"); + assert_eq!( + prompt, + "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" + ); + } + + #[test] + fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "window-project", "window").expect("init"); + for (role, content, message_id) in [ + ("user", "old ".repeat(1_000), "old-user"), + ("assistant", "middle ".repeat(100), "middle-assistant"), + ("tool", "newest ".repeat(100), "newest-tool"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content, + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let mut llm = test_llm(); + llm.auto_compact_token_limit = 800; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let before = + std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &llm, + ) + .expect("build prompt"); + let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + + assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); + assert!(prompt.contains("tool: newest")); + assert!(!prompt.contains("user: old")); + assert!(prompt.ends_with("user: new request")); + assert_eq!(before, after); + } + + #[test] + fn direct_history_prompt_rejects_current_request_that_exceeds_context() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); + let mut llm = test_llm(); + llm.context_window_tokens = 5_000; + llm.auto_compact_token_limit = 1_000; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(100); + let error = build_direct_codex_history_prompt( + root.path(), + "new-turn", + &"request ".repeat(10_000), + &request, + &llm, + ) + .expect_err("oversized request should fail"); + assert!(error.contains("Direct replay 请求")); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index fcd96667b..fc36389d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let reply = if let Some(emitter) = turn_emitter { + let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); let mut has_streamed = false; let mut latest_accumulated_text = None; @@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner( root, system_prompt, prompt.to_string(), + Some(&client_turn_id), Some(&mut observer), audit, ) @@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner( system_prompt, prompt.to_string(), None, + None, audit, ) .await @@ -4189,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at( .map(|_| ()) } +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "user".to_string(), + content: prompt.trim().to_string(), + agent_id: None, + }, + &format!("direct-codex:{client_turn_id}:user"), + ) + .map(|_| ()) +} + #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4220,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; + if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { + audit.finish(false); + turn_emitter.emit("failed", Some("none"), None); + return Err(redact_agent_runtime_error( + root, + &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), + 500, + )); + } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4261,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + fn direct_test_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "fixture-secret".to_string(), + base_url: "https://example.invalid/v1".to_string(), + model: "fixture-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 64_000, + tool_output_token_limit: 12_000, + request_timeout_ms: 10_000, + max_retries: 0, + retry_backoff_ms: 100, + } + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( @@ -4326,6 +4377,83 @@ mod tests { ); } + #[test] + fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘") + .expect("init project"); + let turn_id = "client-turn-user-0001"; + let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节"; + + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("persist user prompt before turn"); + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("retry reuses the same user message identity"); + + let conversation = read_local_conversation_for_session_at(root.path(), None, None) + .expect("read project conversation"); + let message_id = format!("direct-codex:{turn_id}:user"); + let persisted = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() == Some(message_id.as_str())) + .collect::>(); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].role, "user"); + assert_eq!(persisted[0].content, normalized_prompt); + assert!( + persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") + .expect_err("same turn identity cannot be rebound") + .contains("messageId 冲突") + ); + } + + #[test] + fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复") + .expect("init project"); + let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + + persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单") + .expect("persist first user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0001", + "主菜单已完成,下一步可以继续扩展关卡。", + ) + .expect("persist first assistant turn"); + persist_direct_codex_user_prompt_at( + root.path(), + "client-turn-0002", + "请在主菜单基础上增加关卡选择", + ) + .expect("persist second user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0002", + "关卡选择页面已加入,并保留主菜单入口。", + ) + .expect("persist second assistant turn"); + + // 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时, + // 两轮完整的 user/assistant 上下文都必须保留。 + let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt( + root.path(), + "client-turn-0003", + "继续实现第二轮需求", + &request, + &direct_test_llm(), + ) + .expect("rebuild history after restart"); + assert_eq!( + prompt, + "user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求" + ); + } + #[test] fn direct_turn_update_payload_is_the_exact_camel_case_contract() { let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index bae479e11..e8c207db6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter { }, ); } + + pub(crate) fn turn_id(&self) -> &str { + &self.turn_id + } } pub(crate) fn start_game_creator_manifest_invalidation_event_sink( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f3a0472a4..e5598b9cd 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -306,56 +306,6 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -function directCodexConversationTurnId( - message: ChatMessage, - role: ChatMessage['role'], -) { - if (message.role !== role) { - return null; - } - const messageId = message.messageId?.trim() ?? ''; - const roleSuffix = `:${role}`; - if ( - !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || - !messageId.endsWith(roleSuffix) - ) { - return null; - } - const turnId = messageId.slice( - DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, - -roleSuffix.length, - ); - return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null; -} - -export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) { - continue; - } - const turnId = directCodexConversationTurnId(message, 'user'); - if (!turnId) { - continue; - } - const assistantMessageId = directCodexConversationMessageId( - turnId, - 'assistant', - ); - if ( - messages.some( - (candidate) => - candidate.role === 'assistant' && - candidate.messageId?.trim() === assistantMessageId, - ) - ) { - continue; - } - return { prompt: message.text, turnId }; - } - return null; -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -557,6 +507,12 @@ export function App({ useState(null); const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); + const directCodexTransientReplyRef = useRef(''); + const directCodexInterruptedPartialRef = useRef<{ + projectPath: string; + text: string; + messageId: string; + } | null>(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -596,6 +552,7 @@ export function App({ setDirectCodexProgress(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -609,6 +566,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; } @@ -1413,10 +1371,10 @@ export function App({ : Date.now(); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; - setDirectCodexProgress('处理失败,正在同步错误'); - setDirectCodexProgressUpdatedAt(updatedAt); setDirectCodexTransientReply(''); setDirectCodexTransientReplyUpdatedAt(null); + setDirectCodexProgress('处理失败,正在同步错误'); + setDirectCodexProgressUpdatedAt(updatedAt); return; } if (payload.status === 'completed') { @@ -1428,6 +1386,7 @@ export function App({ } if (typeof payload.accumulatedText === 'string') { setDirectCodexTransientReply(payload.accumulatedText); + directCodexTransientReplyRef.current = payload.accumulatedText; setDirectCodexTransientReplyUpdatedAt(updatedAt); } }, @@ -2080,9 +2039,8 @@ export function App({ ); if (localProjectPathRef.current === nextProjectPath) { // Any history read started before this terminal append may hold - // a user-only snapshot. Invalidate it before releasing the - // in-memory claim so that stale hydration cannot replay the - // same billable Direct turn. + // A stale history snapshot may still be missing this terminal + // append. Invalidate it before releasing the in-memory claim. projectSupervisorHistoryLoadVersionRef.current += 1; } recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); @@ -2989,9 +2947,6 @@ export function App({ projectConversation.messages, supervisorConversation?.messages ?? [], ); - const unansweredDirectTurn = directCodexProductRuntime - ? unansweredDirectCodexConversationTurn(conversationMessages) - : null; if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -3043,24 +2998,6 @@ export function App({ }); return nextConversationMessages; }); - if (unansweredDirectTurn) { - const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`; - void Promise.resolve().then(() => { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath || - activeDirectCodexTurnRef.current || - recoveredDirectCodexTurnClaimsRef.current.has(claimKey) - ) { - return; - } - recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply({ - prompt: unansweredDirectTurn.prompt, - clientTurnId: unansweredDirectTurn.turnId, - }); - }); - } } catch (error) { if ( projectSupervisorHistoryLoadVersionRef.current !== loadVersion || @@ -5840,6 +5777,23 @@ export function App({ }, }, ); + const persistDirectPartialMessage = (messageId: string, text: string) => + directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, + agentId: null, + messageId, + message: { + role: 'assistant', + // An interrupted partial is intentionally a normal assistant + // record so replay sees exactly what Codex emitted before the + // disconnect; the marker is product data, not UI metadata. + content: `${text.trim()}\nunexpected interrupt happened here`, + agentId: null, + }, + }, + ); const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; recoveredDirectCodexTurnClaimsRef.current.add( recoveredDirectCodexTurnClaimKey, @@ -5854,26 +5808,40 @@ export function App({ setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); setDirectCodexProgressUpdatedAt(Date.now()); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { - // Persist the original user intent and stable turn identity before - // Codex can start any billable or externally visible work. The - // regular conversation writer may race this call, but messageId - // idempotency makes both writers converge on the same record. - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, + const interruptedPartial = directCodexInterruptedPartialRef.current; + if ( + interruptedPartial?.projectPath === directProjectPath && + interruptedPartial.text.trim() + ) { + await persistDirectPartialMessage( + interruptedPartial.messageId, + interruptedPartial.text, + ); + directCodexInterruptedPartialRef.current = null; + } + // Rust owns the normalized user record for attachment turns so the + // durable message includes the same bounded project mapping that is + // sent to Codex. Plain turns keep the optimistic browser write; the + // Rust writer then converges on it through messageId idempotency. + if (!attachments?.length) { + await directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, agentId: null, + messageId: directUserMessageId, + message: { + role: 'user', + content: prompt, + agentId: null, + }, }, - }, - ); + ); + } const directTurnInput: { projectPath: string; prompt: string; @@ -5907,10 +5875,10 @@ export function App({ } } // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the hydrated-turn claim can be + // browser append is redundant, so the in-memory turn claim can be // released without reopening the Provider side effect. Invalidate - // any user-only history snapshot captured before Rust committed the - // terminal reply first. + // any history snapshot captured before Rust committed the terminal + // reply first. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } @@ -5950,6 +5918,23 @@ export function App({ '陶泥儿智能创作', true, ); + const partial = directCodexTransientReplyRef.current.trim(); + if (partial) { + const partialMessageId = + globalThis.crypto?.randomUUID?.() || + `direct-partial-${Date.now().toString(36)}`; + directCodexInterruptedPartialRef.current = { + projectPath: directProjectPath, + text: partial, + messageId: partialMessageId, + }; + try { + await persistDirectPartialMessage(partialMessageId, partial); + } catch { + // The next user send retries this idempotent append before + // constructing the replay prompt. + } + } try { await persistDirectAssistantMessage(visibleMessage); if (localProjectPathRef.current === directProjectPath) { diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts index 5bfe37adf..a65ed50dc 100644 --- a/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts +++ b/apps/ai-game-creator-shell/src/features/ui-editor/useUiEditorState.ts @@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = { font_assets: {}, }; +const MAX_HISTORY_LENGTH = 100; + export type UiEditorOperationFailureReason = | 'locked' | 'duplicate' @@ -45,6 +47,15 @@ export type UiEditorOperationResult = | { ok: true; value: T } | { ok: false; reason: UiEditorOperationFailureReason }; +export type UiEditorHistoryState = { + canUndo: boolean; + canRedo: boolean; +}; + +export type UiEditorReplaceStateOptions = { + history?: 'record' | 'reset' | 'skip'; +}; + type UiEditorOperationFailure = Extract; export type NodeMetadataPatch = Partial< @@ -227,8 +238,47 @@ function cloneState(state: State): State { return structuredClone(state); } -function sameResource(left: T, right: T): boolean { - return JSON.stringify(left) === JSON.stringify(right); +function sameResource( + left: T, + right: T, + seenPairs = new WeakMap>(), +): boolean { + if (Object.is(left, right)) return true; + if ( + typeof left !== 'object' || + left === null || + typeof right !== 'object' || + right === null + ) { + return false; + } + const leftObject = left as object; + const rightObject = right as object; + let seenRightObjects = seenPairs.get(leftObject); + if (seenRightObjects?.has(rightObject)) return true; + if (!seenRightObjects) { + seenRightObjects = new WeakSet(); + seenPairs.set(leftObject, seenRightObjects); + } + seenRightObjects.add(rightObject); + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => + sameResource(value, right[index], seenPairs), + ); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + sameResource(leftRecord[key], rightRecord[key], seenPairs), + ); } function visitComponents(nodes: Node[], visit: (component: Component) => void) { @@ -505,15 +555,82 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { return next; }); const [isLocked, setIsLocked] = useState(false); + const [historyState, setHistoryState] = useState({ + canUndo: false, + canRedo: false, + }); const stateRef = useRef(state); const isLockedRef = useRef(false); + const undoStackRef = useRef>([]); + const redoStackRef = useRef>([]); + const pendingHistoryBeforeRef = useRef(null); stateRef.current = state; - const commit = useCallback((nextState: State) => { + const syncHistoryState = useCallback(() => { + setHistoryState({ + canUndo: undoStackRef.current.length > 0, + canRedo: redoStackRef.current.length > 0, + }); + }, []); + + const applyState = useCallback((nextState: State) => { stateRef.current = nextState; setState(nextState); }, []); + const commit = useCallback( + (nextState: State) => { + const current = stateRef.current; + const before = pendingHistoryBeforeRef.current ?? current; + if (sameResource(before, nextState)) { + pendingHistoryBeforeRef.current = null; + return false; + } + undoStackRef.current.push({ + before: cloneState(before), + after: nextState, + }); + if (undoStackRef.current.length > MAX_HISTORY_LENGTH) { + undoStackRef.current.shift(); + } + redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; + syncHistoryState(); + applyState(nextState); + return true; + }, + [applyState, syncHistoryState], + ); + + const resetHistory = useCallback(() => { + undoStackRef.current = []; + redoStackRef.current = []; + pendingHistoryBeforeRef.current = null; + syncHistoryState(); + }, [syncHistoryState]); + + const undo = useCallback(() => { + if (isLockedRef.current) return false; + const entry = undoStackRef.current.pop(); + if (!entry) return false; + pendingHistoryBeforeRef.current = null; + redoStackRef.current.push(entry); + applyState(cloneState(entry.before)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + + const redo = useCallback(() => { + if (isLockedRef.current) return false; + const entry = redoStackRef.current.pop(); + if (!entry) return false; + pendingHistoryBeforeRef.current = null; + undoStackRef.current.push(entry); + applyState(cloneState(entry.after)); + syncHistoryState(); + return true; + }, [applyState, syncHistoryState]); + const guard = useCallback((): UiEditorOperationFailure | null => { // Every semantic write exits before reading or committing State while locked. return isLockedRef.current ? { ok: false, reason: 'locked' } : null; @@ -534,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { try { return await operation(snapshot); } finally { + pendingHistoryBeforeRef.current = null; isLockedRef.current = false; setIsLocked(false); } @@ -1397,22 +1515,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) { const clearState = useCallback((): UiEditorOperationResult => { const blocked = guard(); if (blocked) return blocked; - commit(cloneState(EMPTY_UI_EDITOR_STATE)); + resetHistory(); + applyState(cloneState(EMPTY_UI_EDITOR_STATE)); return { ok: true, value: undefined }; - }, [commit, guard]); + }, [applyState, guard, resetHistory]); const replaceState = useCallback( - (nextState: State) => { + (nextState: State, options: UiEditorReplaceStateOptions = {}) => { const next = cloneState(nextState); synchronizeDesignImageTrees(next); - commit(next); + if (options.history === 'reset') { + resetHistory(); + applyState(next); + } else if (options.history === 'skip') { + if (!pendingHistoryBeforeRef.current) { + pendingHistoryBeforeRef.current = cloneState(stateRef.current); + } + applyState(next); + } else { + commit(next); + } }, - [commit], + [applyState, commit, resetHistory], ); return { state, + historyState, + undo, + redo, + resetHistory, + isLocked, runWithStateLocked, setImageName, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx index 9285614f4..d576a7ccb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/TextPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type RgbaColor, RgbaColorPicker } from 'react-colorful'; import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing'; @@ -21,19 +21,49 @@ export function TextPanel({ onChange, }: TextEditorProps) { const [colorOpen, setColorOpen] = useState(false); - const rgba: RgbaColor = { + const [colorDraft, setColorDraft] = useState(null); + const colorDraftRef = useRef(null); + const componentRef = useRef(component); + const onChangeRef = useRef(onChange); + componentRef.current = component; + onChangeRef.current = onChange; + const committedRgba: RgbaColor = { r: component.color[0] ?? 255, g: component.color[1] ?? 255, b: component.color[2] ?? 255, a: (component.color[3] ?? 255) / 255, }; + const rgba = colorDraft ?? committedRgba; + useEffect(() => { + colorDraftRef.current = null; + setColorDraft(null); + }, [component.color]); + useEffect( + () => () => { + const draft = colorDraftRef.current; + if (!draft) return; + onChangeRef.current({ + ...componentRef.current, + color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)], + }); + }, + [], + ); const bestFit = 'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null; - const updateColor = (next: RgbaColor) => + const commitColor = (next: RgbaColor = rgba) => { + if (!colorDraftRef.current) return; + colorDraftRef.current = null; + setColorDraft(null); onChange({ ...component, color: [next.r, next.g, next.b, Math.round(next.a * 255)], }); + }; + const toggleColorOpen = () => { + if (colorOpen) commitColor(); + setColorOpen((open) => !open); + }; return (
@@ -159,7 +189,7 @@ export function TextPanel({ type="button" className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40" disabled={readOnly} - onClick={() => setColorOpen((open) => !open)} + onClick={toggleColorOpen} > {colorOpen && !readOnly ? (
- +
{ + colorDraftRef.current = null; + setColorDraft(null); + }} + > + { + colorDraftRef.current = next; + setColorDraft(next); + }} + onChangeEnd={commitColor} + /> +
+ value={Math.round(rgba.a * 255)} + onChange={(event) => { + colorDraftRef.current = null; + setColorDraft(null); onChange({ ...component, - color: [ - component.color[0], - component.color[1], - component.color[2], - Number(event.target.value), - ], - }) - } + color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)], + }); + }} />
) : null} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index ec88d9571..c2cae9af5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -24,6 +24,7 @@ import { } from 'react'; import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry'; +import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; @@ -48,6 +49,9 @@ export function PreviewWorkspace({ const [renderMode, setRenderMode] = useState('editor-overlay'); const [showFrame, setShowFrame] = useState(false); + const [previewTransforms, setPreviewTransforms] = useState< + ReadonlyMap + >(new Map()); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; @@ -55,6 +59,22 @@ export function PreviewWorkspace({ isPageRoot: boolean; } | null>(null); const tree = canvas.tree ?? null; + const updatePreviewTransform = useCallback( + (nodeId: NodeId, transform: Node['layout']['transform'] | null) => { + setPreviewTransforms((current) => { + if (!transform && !current.has(nodeId)) return current; + if (transform && current.get(nodeId) === transform) return current; + const next = new Map(current); + if (transform) next.set(nodeId, transform); + else next.delete(nodeId); + return next; + }); + }, + [], + ); + useEffect(() => { + setPreviewTransforms(new Map()); + }, [activeImageId]); const activeImagePixelWidth = activeImage?.pixel_size[0]; const activeImagePixelHeight = activeImage?.pixel_size[1]; const activeImagePixelsPerUnit = activeImage?.pixels_per_unit; @@ -87,7 +107,9 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, + onPreviewTransform: updatePreviewTransform, }); const { onNodePointerDown, @@ -382,6 +404,7 @@ export function PreviewWorkspace({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={canvas.hiddenNodeIds} + previewTransforms={previewTransforms} selectedNodeId={canvas.selectedNodeId} resources={{ previewUrls, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index ad91c0571..38d1b9454 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -32,6 +32,7 @@ type UiTreeRendererProps = { renderMode: UiEditorRenderMode; showFrame: boolean; hiddenNodeIds: ReadonlySet; + previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; @@ -70,6 +71,11 @@ const RESIZE_HANDLES: ReadonlyArray<{ { id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' }, ]; +const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap< + NodeId, + UiNode['layout']['transform'] +> = new Map(); + function RenderNode({ node, isRoot, @@ -77,6 +83,7 @@ function RenderNode({ renderMode, showFrame, hiddenNodeIds, + previewTransforms, selectedNodeId, resources, onSelectNode, @@ -94,14 +101,16 @@ function RenderNode({ isRoot?: boolean; parentContainer?: UiNode['layout']['container']; }) { + const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; if (hiddenNodeIds.has(node.id)) return null; + const previewTransform = activePreviewTransforms.get(node.id); + const layout = previewTransform + ? { ...node.layout, transform: previewTransform } + : node.layout; let geometry; try { - geometry = controlLayoutToPreviewCss( - node.layout, - parentContainer !== undefined, - ); + geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined); } catch { // Keep malformed nodes isolated from the rest of the tree. return null; @@ -120,7 +129,7 @@ function RenderNode({ style={{ ...geometry, ...(parentContainer - ? childInContainerToPreviewCss(node.layout, parentContainer) + ? childInContainerToPreviewCss(layout, parentContainer) : {}), ...containerToPreviewCss(node.layout.container), ...(isFrameVisible @@ -189,6 +198,7 @@ function RenderNode({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={hiddenNodeIds} + previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts index 1d22c3531..74f652acb 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/useNodeTransformInteraction.ts @@ -9,9 +9,12 @@ import { import { findNodePageContext, type PageRect, + pageRectFromSize, type ResizeAxis, type ResizeHandle, resizePageRect, + resolveChildrenTransformsForParentRect, + resolvePageRect, resolveProportionalResizeAxis, setOffsetsForPageRect, } from '../../../../features/ui-editor/nodeTransformGeometry'; @@ -29,6 +32,8 @@ type GestureBase = { startClientY: number; startTransform: UiNode['layout']['transform']; hasMoved: boolean; + pendingTransform?: UiNode['layout']['transform']; + previewNodeIds: string[]; }; type ActiveGesture = @@ -78,26 +83,100 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function previewNodeIds( + tree: UITree | null, + nodeId: string, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, +) { + if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId]; + const context = findNodePageContext( + tree.root, + nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + return context + ? [nodeId, ...context.node.children.map((child) => child.id)] + : [nodeId]; +} + +function emitPreviewTransforms( + gesture: ActiveGesture, + tree: UITree | null, + logicalSize: { width: number; height: number } | null, + keepChildrenUnchanged: boolean, + onPreviewTransform: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void, +) { + onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null); + if ( + !gesture.pendingTransform || + !keepChildrenUnchanged || + !tree || + !logicalSize + ) { + return; + } + const context = findNodePageContext( + tree.root, + gesture.nodeId, + pageRectFromSize([logicalSize.width, logicalSize.height]), + ); + if (!context) return; + const newNodeRect = resolvePageRect( + gesture.pendingTransform, + context.parentRect, + ); + if (!isFiniteRect(newNodeRect)) return; + for (const child of resolveChildrenTransformsForParentRect( + context.node.children, + context.rect, + newNodeRect, + )) { + onPreviewTransform(child.id, child.transform); + } +} + export function useNodeTransformInteraction({ activeImageId, canvas, logicalSize, spaceHeld, tree, + keepChildrenUnchanged, viewportRef, + onPreviewTransform, }: { activeImageId: UiEditorCanvasProjection['activeImageId']; canvas: Pick; logicalSize: { width: number; height: number } | null; spaceHeld: boolean; tree: UITree | null; + keepChildrenUnchanged: boolean; viewportRef: RefObject; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; }) { const activeGestureRef = useRef(null); + // Keep cleanup stable while still invoking the latest preview callback. + const onPreviewTransformRef = useRef(onPreviewTransform); + onPreviewTransformRef.current = onPreviewTransform; const cancelGesture = useCallback(() => { const gesture = activeGestureRef.current; - if (gesture) releasePointer(gesture.target, gesture.pointerId); + if (gesture) { + releasePointer(gesture.target, gesture.pointerId); + onPreviewTransformRef.current?.(gesture.nodeId, null); + for (const nodeId of gesture.previewNodeIds) { + if (nodeId !== gesture.nodeId) { + onPreviewTransformRef.current?.(nodeId, null); + } + } + } activeGestureRef.current = null; }, []); @@ -151,9 +230,22 @@ export function useNodeTransformInteraction({ startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodePointerMove = useCallback( @@ -185,18 +277,45 @@ export function useNodeTransformInteraction({ cancelGesture(); return; } - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); const onNodePointerUp = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); + const gesture = activeGestureRef.current; + if ( + event.type !== 'pointercancel' && + gesture?.hasMoved && + gesture.pendingTransform + ) { + canvas.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + gesture.pendingTransform, + ); + } cancelGesture(); }, - [acceptsGestureEvent, cancelGesture], + [acceptsGestureEvent, cancelGesture, canvas], ); const onNodeResizePointerDown = useCallback( @@ -251,9 +370,22 @@ export function useNodeTransformInteraction({ parentRect: context.parentRect, ratioAxis: null, hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [ + activeImageId, + canvas, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + tree, + ], ); const onNodeResizePointerMove = useCallback( @@ -310,9 +442,24 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + gesture.pendingTransform = nextTransform; + emitPreviewTransforms( + gesture, + tree, + logicalSize, + keepChildrenUnchanged, + (nodeId, transform) => + onPreviewTransformRef.current?.(nodeId, transform), + ); }, - [acceptsGestureEvent, cancelGesture, canvas, viewportRef], + [ + acceptsGestureEvent, + cancelGesture, + keepChildrenUnchanged, + logicalSize, + tree, + viewportRef, + ], ); return { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index 84267aad5..241185424 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, Redo2, Undo2 } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { ThemedModal } from '../../components/modal/ThemedModal'; @@ -77,11 +77,44 @@ export default function UiEditorPage({ Boolean(session.save.loadError) || session.save.persistedRevision === null || session.save.isLocked; + const historyUndo = session.history.undo; + const historyRedo = session.history.redo; useEffect(() => { if (session.save.isDirty) setGenerateSuccess(null); }, [session.save.isDirty]); + useEffect(() => { + const isEditableTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.repeat || + event.defaultPrevented || + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && historyUndo()) { + event.preventDefault(); + } else if (isRedo && historyRedo()) { + event.preventDefault(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [historyRedo, historyUndo]); + async function save(afterReturn = false) { if (await session.save.save()) { if (afterReturn) { @@ -155,6 +188,26 @@ export default function UiEditorPage({ {resourceLabel ?? 'UI 设计'}
+ + {walletEntry ? (
{walletEntry}
) : null} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index ae17854be..b2c98484b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -274,7 +274,7 @@ export function useUiEditorSession( .load(resourceId) .then(({ state, revision }) => { if (!cancelled) { - replaceEditorState(state); + replaceEditorState(state, { history: 'reset' }); const ids = Object.keys( state.ui_design_images, ).sort() as UIDesignImageId[]; @@ -1028,7 +1028,9 @@ export function useUiEditorSession( spriteIds, }); current = applyBindingResult(current, result); - editor.replaceState(current); + editor.replaceState(current, { + history: index < batches.length - 1 ? 'skip' : 'record', + }); } setBindingStatus( `组件绑定完成(${batches.length}/${batches.length})。`, @@ -1214,6 +1216,7 @@ export function useUiEditorSession( tree: treeForActiveImage ?? null, selectedNode: selectedNodeContext?.node ?? null, selectedNodeId, + keepChildrenUnchanged, hiddenNodeIds, focusRequest, status, @@ -1228,6 +1231,11 @@ export function useUiEditorSession( deleteNode, openClearDialog: () => setClearOpen(true), }, + history: { + ...editor.historyState, + undo: editor.undo, + redo: editor.redo, + }, inspector: { isLocked: editor.isLocked, projectPath, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index c76d78419..5493fb6cf 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2354,303 +2354,6 @@ export function registerHomeProjectCreationTests() { ), ).toHaveLength(1); }); - - it('releases a hydrated Direct Codex turn claim after an in-progress rejection so the same App can resume it later', async () => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\running-direct-project'; - const manifest = createGameCreationAppManifest( - 'running-direct-project', - '运行中直连项目', - ); - const stableTurnId = 'stable-running-turn-001'; - let directTurnCallCount = 0; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '继续完成运行中的项目', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - if (directTurnCallCount === 1) { - throw new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ); - } - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText('继续完成运行中的项目')).not.toBeNull(); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath, - prompt: '继续完成运行中的项目', - clientTurnId: stableTurnId, - }, - ); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(persistedMessages).toHaveLength(1); - expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId === - `direct-codex:${stableTurnId}:assistant`, - ), - ).toHaveLength(0); - expect(screen.queryByText(/direct-codex-turn-already-running/)).toBeNull(); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect(await screen.findByText('恢复后的最终回复')).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(2); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: '恢复后的最终回复', - }), - ]); - }); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_local_conversation', - ).length, - ).toBeGreaterThanOrEqual(2); - }); - - it.each([ - { - caseName: 'a successful reply', - firstError: null, - firstReply: '首次成功回复', - firstVisibleText: '首次成功回复', - }, - { - caseName: 'an ordinary error reply', - firstError: 'codex-app-server-error:unauthorized', - firstReply: null, - firstVisibleText: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', - }, - ])( - 'reconciles a hydrated Direct Codex claim after persisting $caseName fails', - async ({ firstError, firstReply, firstVisibleText }) => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\retry-terminal-persistence'; - const manifest = createGameCreationAppManifest( - 'retry-terminal-persistence', - '终态持久化重试项目', - ); - const stableTurnId = 'stable-terminal-persistence-001'; - let directTurnCallCount = 0; - let allowAssistantPersistence = false; - let failedAssistantPersistenceCount = 0; - const directTurnIds: string[] = []; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '恢复终态持久化失败的回合', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - if (message.role === 'assistant' && !allowAssistantPersistence) { - failedAssistantPersistenceCount += 1; - throw new Error('assistant conversation persistence unavailable'); - } - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - directTurnIds.push(String(args?.clientTurnId ?? '')); - if (directTurnCallCount === 1) { - if (firstError) { - throw new Error(firstError); - } - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content: firstReply, - agentId: null, - messageId: `direct-codex:${stableTurnId}:assistant`, - updatedAt: 2, - }); - return firstReply ?? ''; - } - allowAssistantPersistence = true; - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText(firstVisibleText)).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(1); - expect(failedAssistantPersistenceCount).toBeGreaterThan( - firstError ? 1 : 0, - ); - expect(persistedMessages).toHaveLength(firstError ? 1 : 2); - }); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect( - await screen.findByText(firstError ? '恢复后的最终回复' : firstReply!), - ).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(firstError ? 2 : 1); - expect(directTurnIds).toEqual( - firstError ? [stableTurnId, stableTurnId] : [stableTurnId], - ); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: firstError ? '恢复后的最终回复' : firstReply, - }), - ]); - }); - }, - ); } export function registerRecentProjectsTests() { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index c6d0fbe0d..147b5f7ce 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,4 @@ -import { - isDirectCodexTurnAlreadyRunningError, - unansweredDirectCodexConversationTurn, -} from '../../src/App'; +import { isDirectCodexTurnAlreadyRunningError } from '../../src/App'; import { act, agentRuntimeUserInputRequest, @@ -26,66 +23,6 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('replays only the latest unanswered Direct Codex turn with its original stable identity', () => { - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - ]), - ).toEqual({ - prompt: '请重新生成美术', - turnId: 'stable-turn-001', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - { - role: 'assistant', - text: '已完成', - messageId: 'direct-codex:stable-turn-001:assistant', - }, - ]), - ).toBeNull(); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '恢复较早的未回答回合', - messageId: 'direct-codex:stable-turn-older:user', - }, - { - role: 'user', - text: '较新的已回答回合', - messageId: 'direct-codex:stable-turn-newer:user', - }, - { - role: 'assistant', - text: '较新的回复', - messageId: 'direct-codex:stable-turn-newer:assistant', - }, - ]), - ).toEqual({ - prompt: '恢复较早的未回答回合', - turnId: 'stable-turn-older', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '伪造回合', - messageId: 'direct-codex:../unsafe:user', - }, - ]), - ).toBeNull(); - }); - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { expect( isDirectCodexTurnAlreadyRunningError( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index 43c9dac4d..89c4cdf3e 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -603,4 +603,149 @@ describe('useUiEditorState', () => { }), ]); }); + + it('records, undoes, redoes, and clears redo after a new edit', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', '第一次'); + }); + expect(result.current.historyState).toEqual({ + canUndo: true, + canRedo: false, + }); + + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: true, + }); + + act(() => { + expect(result.current.redo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第一次', + ); + + act(() => { + result.current.setImageName('page', '第二次'); + expect(result.current.redo()).toBe(false); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '第二次', + ); + }); + + it('does not record no-op edits and resets history when replacing loaded state', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.setImageName('page', 'Page'); + }); + expect(result.current.historyState.canUndo).toBe(false); + + act(() => { + result.current.setImageName('page', '编辑后'); + result.current.replaceState(initial, { history: 'reset' }); + }); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); + + act(() => { + result.current.setImageName('page', '清空前'); + result.current.clearState(); + }); + expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: false, + }); + }); + + it('records each replacement as an independent history entry', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('中间') }, + }); + result.current.replaceState({ + ...initial, + ui_design_images: { page: image('最终') }, + }); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '中间', + ); + }); + + it('records one history entry after skipped replacement batches', () => { + const initial: State = { + ...structuredClone(EMPTY_UI_EDITOR_STATE), + ui_design_images: { page: image('Page') }, + }; + const { result } = renderHook(() => useUiEditorState(initial)); + + act(() => { + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('第一批') }, + }, + { history: 'skip' }, + ); + result.current.replaceState( + { + ...initial, + ui_design_images: { page: image('最终') }, + }, + { history: 'record' }, + ); + }); + + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + '最终', + ); + expect(result.current.historyState).toEqual({ + canUndo: true, + canRedo: false, + }); + act(() => { + expect(result.current.undo()).toBe(true); + }); + expect(result.current.state.ui_design_images.page?.metadata.name).toBe( + 'Page', + ); + expect(result.current.historyState).toEqual({ + canUndo: false, + canRedo: true, + }); + }); }); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index 2b79ab689..bd5f2071a 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -44,6 +44,17 @@ const pageTree: UITree = { root: node('root', [child]), }; +function transformedNode( + id: string, + transform: UiNode['layout']['transform'], + children: UiNode[] = [], +) { + return { + ...node(id, children), + layout: { ...node(id).layout, transform }, + }; +} + function gestureTarget() { const target = document.createElement('div'); Object.assign(target, { @@ -83,10 +94,17 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + keepChildrenUnchanged = false, + onPreviewTransform, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + keepChildrenUnchanged?: boolean; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -99,6 +117,8 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, viewportRef, + keepChildrenUnchanged, + onPreviewTransform, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -106,6 +126,97 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('previews stable child page rectangles when the parent moves', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 1, + 'parent', + expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }), + ); + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + + it('previews stable child page rectangles when the parent resizes', () => { + const nestedChild = transformedNode('child', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [10, 10], + offset_max: [60, 50], + }); + const parent = transformedNode( + 'parent', + { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [120, 100], + }, + [nestedChild], + ); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [parent]), + }; + const onPreviewTransform = vi.fn(); + const { result } = renderInteraction({ + tree, + keepChildrenUnchanged: true, + onPreviewTransform, + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodeResizePointerDown( + pointerEvent(target, 1, 0, 0), + parent, + 'nw', + ); + result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenNthCalledWith( + 2, + 'child', + expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }), + ); + }); + it('makes drag and resize mutually exclusive, then permits the next gesture', () => { const { result, canvasProjection } = renderInteraction(); const dragTarget = gestureTarget(); @@ -125,7 +236,7 @@ describe('useNodeTransformInteraction', () => { }); expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled(); - expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); + expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled(); act(() => { result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8)); @@ -135,6 +246,7 @@ describe('useNodeTransformInteraction', () => { 'se', ); }); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); diff --git a/docs/README.md b/docs/README.md index c9fc05a53..610380457 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) - [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md) - [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md) +- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md) ## 图片画布与媒体 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00e3114ee..293d9b841 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7623,6 +7623,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 配置:有无 AGC LLM Key 都只支持 `openai_responses`;非空 Key 映射后 base URL 与逐 Agent model/effort 生效,Key 只经专用环境变量;空 Key 只桥接用户 Codex `auth.json`,移除继承环境 Key。`stream=true` 复用现有 durable final-reply delta;`webSearchEnabled=true`、`openai_chat / anthropic` 必须显式切换 `provider`,不得静默忽略已有 LLM 配置。 - 安全与恢复:app-server 使用隔离临时 `CODEX_HOME` 与 OS HOME,只桥接认证,不加载用户 MCP/config/skills/hooks;启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力,固定 read-only、network off、never approval,AGC 是唯一 ToolHost。取消覆盖 turn-start 回包前窗口并只发送单 turn interrupt;已开始 turn 的连接/终态未知直接标记 reconciliation,明确 failed/interrupted 不自动重试。模式与 durable 指纹取同一配置快照。 - 资源与退出:pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离;空 AppData Key 只允许桥接一次性读取的有界 `auth.json` 快照,并用同一字节快照生成池指纹,宿主 `CODEX_API_KEY` 对 app-server 与一次性 CLI 都必须移除。节点进程与 thread 均有上限并只淘汰 inactive LRU;stdout NDJSON 与 stderr 无换行记录均有硬上限,stderr 原文不得写入错误或日志,只记录固定分类、总字节数、SHA-256 与可取得的退出状态。Runner 正常、强制、watchdog 退出显式关池;Linux child 绑定 parent-death signal,避免 Runner 被强杀后遗留带凭据孤儿进程。 +- DirectProject 会话恢复补充:Codex thread 继续使用 `ephemeral=true`,不保存或恢复 Codex 原生 thread。项目对话 `.agent/conversations/project.jsonl` 是唯一聊天事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 行,按原顺序渲染为简单的 `user:` / `assistant:` / `tool:` 文本后,再追加本次新 user 请求发送给新 thread;已有 thread 的普通消息仍只发送新 user。app-server 意外中断时,已收到的 partial 文本作为普通 `assistant` 消息追加,并在末尾写入 `unexpected interrupt happened here`;断开处理与下一次发送均可重复追加,但使用普通消息 `messageId` 幂等。项目打开不再根据“只有 user 没有 assistant”自动重发旧请求;Direct 不新增 retry 入口。Runtime Agent 的恢复合同保持独立,不消费项目 Direct 对话历史。 - 兼容迁移:已有 AppData 未写 `agentMode` 时,仅当全局及逐 Agent 都是 `openai_responses` 才迁入 app-server;任何 `openai_chat / anthropic` 路由保持 `provider`,防止项目自动恢复先于用户改配置而批量失败。新安装仍默认 app-server;已确认兼容 Responses 的旧端点可由用户显式切换且继续使用原 model/base URL/API Key。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.52。 @@ -7948,6 +7949,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。 - 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。 +## 2026-09-02 DirectProject replay 取舍与 thread 原子判定 + +- DirectProject 的全量 replay、简单 `user:` / `assistant:` / `tool:` 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)都是有意的当前产品合同:分别保证 AGC JSONL 事实源无损重建、保持 prompt 形状稳定且不引入 envelope breaking change、让模型明确知道上次输出在中断处结束。后续若调整任一项,必须先更新恢复合同与兼容策略。 +- replay 与 thread 创建必须使用同一临界区结果。`turn_gate` 内的 `thread_for()` 原子返回 `(CodexThreadLease, created)`;只有 `created=true` 时才读取项目 JSONL 并构造历史 prompt,复用已有 thread 时只发送当前 user,避免并发首请求重复注入历史。 + +## 2026-09-03 DirectProject replay 有界滑动窗口 + +- DirectProject 继续以 `.agent/conversations/project.jsonl` 作为不可变、append-only 唯一事实源;窗口只生成本次恢复请求的派生 prompt,不写回 JSONL,不创建 Runtime compaction summary 或 sidecar。 +- 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。 +- 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。 + ## 2026-08-31 AGC 错误报告与诊断上传 - AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ce63e5020..8e886e8a4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -226,6 +226,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。 diff --git a/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md new file mode 100644 index 000000000..6988d039d --- /dev/null +++ b/docs/【UI编辑器】拖动变换提交边界-2026-09-03.md @@ -0,0 +1,14 @@ +# UI 编辑器拖动变换提交边界 + +## 当前约定 + +UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。 + +指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。 + +## 实现边界 + +- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。 +- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。 +- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。 +- 拖动和缩放继续共用单指针捕获与有限数校验。 diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md new file mode 100644 index 000000000..f3912e000 --- /dev/null +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md @@ -0,0 +1,69 @@ +# UI 编辑器撤销与重做规范 + +## 目标 + +UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被撤销的编辑,降低误操作返工成本,同时保持现有保存、AI 工作流和资产文件行为不变。 + +## 适用范围 + +撤销历史属于当前 UI 编辑会话,历史只保存可序列化的编辑器 `State` 快照,不保存页面临时状态。 + +纳入历史的操作: + +- 设计图名称、描述、角色和从属关系修改; +- 设计图、精灵、字体资源的新增、删除和元数据修改; +- 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改; +- 组件新增、删除、排序和字段修改; +- AI suggest、recognize、merge,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录; +- bind 按后端批次逐次提交,每个成功 batch 作为一条独立记录,便于逐批撤销; +- 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。 + +不纳入历史的操作: + +- 选择项、隐藏节点、工作流步骤、面板展开状态和画布视口等 UI 临时状态; +- 打开/切换项目、服务端重新加载和清空编辑器;这些操作替换 State 后重置历史; +- 保存、自动保存、生成代码、发布请求本身;撤销只改变本地 State,后续保存才同步远端; +- 素材上传、AI 生成等已发生的外部副作用;若副作用同时落地了本地 State,只撤销本地 State 变化; +- no-op、锁定、校验失败或目标不存在的操作。 + +导入资源被撤销时只回退编辑器 State 中的资源记录和引用,不删除已经写入磁盘的资产文件;重做恢复原资源 ID 与文件引用。 + +## 历史模型 + +- 栈按会话全局维护,最多保留最近 100 条事务;超限丢弃最旧记录。 +- 每条记录保存 `before` 与 `after` 的完整结构化 State 快照,快照不复制二进制文件内容。 +- 提交前后快照相同则不产生记录。 +- 撤销将当前 State 恢复为记录的 `before`,并把记录移入 redo 栈;重做恢复 `after`。 +- 撤销后发生新的有效编辑时清空 redo 栈。 +- 撤销/重做恢复 State 时不得再次写入历史。 + +## 事务边界 + +- 普通字段、按钮和列表操作一次成功调用对应一条记录。 +- 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。 +- 每次有效 `commit` 或 `replaceState` 都直接生成一条记录;bind 的每个成功 batch 独立提交。失败 batch 不产生记录,已完成的前序 batch 保留。 +- 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。 + +## 用户入口 + +- 桌面端页面工具栏提供撤销和重做按钮。 +- 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。 +- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 +- 无可撤销或重做记录时按钮禁用,并提供可访问名称。 + +## 脏状态与选择 + +撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 + +## 验收标准 + +1. 单次字段编辑可撤销和重做。 +2. 连续多次编辑按逆序撤销。 +3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。 +4. AI suggest/recognize/merge 一次调用只产生一条记录;bind 每个成功 batch 产生一条记录,失败不产生该 batch 记录。 +5. 撤销后新编辑清空 redo。 +6. 加载/切换/清空重置历史;保存/自动保存不清空历史。 +7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。 +8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 +9. no-op、锁定、校验失败和不存在目标不进入历史。 +10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。