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 ac4e6adb0..62badfae7 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 80481f51b..e0507c47b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -297,56 +297,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 @@ -544,6 +494,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, @@ -583,6 +539,7 @@ export function App({ setDirectCodexProgress(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -596,6 +553,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; } @@ -1234,10 +1192,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') { @@ -1249,6 +1207,7 @@ export function App({ } if (typeof payload.accumulatedText === 'string') { setDirectCodexTransientReply(payload.accumulatedText); + directCodexTransientReplyRef.current = payload.accumulatedText; setDirectCodexTransientReplyUpdatedAt(updatedAt); } }, @@ -1844,9 +1803,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); @@ -2702,9 +2660,6 @@ export function App({ projectConversation.messages, supervisorConversation?.messages ?? [], ); - const unansweredDirectTurn = directCodexProductRuntime - ? unansweredDirectCodexConversationTurn(conversationMessages) - : null; if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -2756,24 +2711,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 || @@ -5464,6 +5401,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, @@ -5478,26 +5432,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; @@ -5531,10 +5499,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; } @@ -5574,6 +5542,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/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 7cc1c8444..fa9630719 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,5 +1,5 @@ import { listen } from '@tauri-apps/api/event'; -import { Download, LoaderCircle } from 'lucide-react'; +import { Download, LoaderCircle, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; @@ -117,6 +117,16 @@ export function AppUpdateNotice() { > {isDownloading ? '正在下载…' : '下载更新'} + {downloadState !== 'idle' ? (
= | { 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/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0d8b31f97..dbac2f8dc 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -75,6 +75,27 @@ body { cursor: wait; opacity: 0.65; } +.app-update-notice .app-update-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: #8d6a58; + cursor: pointer; +} +.app-update-notice .app-update-close:hover { + background: rgb(199 101 61 / 12%); + color: #4a220f; +} +.app-update-notice .app-update-close:disabled { + cursor: wait; + opacity: 0.5; +} .app-update-overlay { position: fixed; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 83e8ff1ae..495db5bd9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -1,6 +1,7 @@ import { Image as ImageIcon, Plus, Type } from 'lucide-react'; import { useMemo } from 'react'; +import { SPRITE_CHECKERBOARD_CLASS_NAME } from '../../../features/ui-editor/components/SpriteImagePreview'; import { visitUiNodes } from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; @@ -184,7 +185,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { + + + + +
+

选择图片素材

+ +
+ +
+
+ + + {value && !selectedSprite ? ( +
+
+ ) : null} + + {Object.entries(sprites).map(([id, sprite]) => { + const previewUrl = previewUrls[id]; + const hasPreview = + Boolean(previewUrl) && failedPreviewUrls[id] !== previewUrl; + const selected = value === id; + return ( + + ); + })} +
+ {Object.keys(sprites).length === 0 ? ( +

+ 暂无可用素材 +

+ ) : null} +
+
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx index 176021783..06b3e8b53 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ImagePanel.tsx @@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; import type { ImageEditorProps } from './componentEditorTypes'; import { ComponentNumberInput, ComponentSelect } from './ComponentField'; +import { ImageAssetSelector } from './ImageAssetSelector'; export function ImagePanel({ component, sprites, + previewUrls, readOnly, onChange, }: ImageEditorProps) { @@ -17,24 +19,13 @@ export function ImagePanel({ return (
- - update({ - ...component, - target_graphic: event.target.value || null, - }) - } - > - - {Object.entries(sprites).map(([id, sprite]) => ( - - ))} - + update({ ...component, target_graphic })} + /> (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/Inspector/Components/componentEditorTypes.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts index d93eb8503..9d3adc06a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts @@ -9,6 +9,7 @@ import type { UiEditorOperationResult } from '../../../../../features/ui-editor/ export type ComponentPanelProps = { components: Component[]; sprites: Record; + previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; @@ -35,6 +36,7 @@ export type ComponentEditorProps = { export type ImageEditorProps = ComponentEditorProps & { sprites: Record; + previewUrls: Record; }; export type TextEditorProps = ComponentEditorProps & { diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index ca0d35db0..7557c3727 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -111,6 +111,7 @@ export function InspectorSidebar({ onTransformChange={inspector.setNodeTransform} onLayoutChange={inspector.setNodeLayout} sprites={inspector.sprites} + previewUrls={inspector.previewUrls} fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} @@ -288,6 +289,7 @@ function NodeInspector({ onTransformChange, onLayoutChange, sprites, + previewUrls, fonts, fontFaces, projectPath, @@ -317,6 +319,7 @@ function NodeInspector({ onTransformChange: UiEditorInspectorProjection['setNodeTransform']; onLayoutChange: UiEditorInspectorProjection['setNodeLayout']; sprites: UiEditorInspectorProjection['sprites']; + previewUrls: UiEditorInspectorProjection['previewUrls']; fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; @@ -497,6 +500,7 @@ function NodeInspector({ = { const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch']; +const CORNERS: readonly { + id: Corner; + label: string; +}[] = [ + { id: 'top-left', label: '左上角' }, + { id: 'top-right', label: '右上角' }, + { id: 'bottom-left', label: '左下角' }, + { id: 'bottom-right', label: '右下角' }, +]; + +function CornerIcon({ + corner, + active = false, +}: { + corner: Corner; + active?: boolean; +}) { + const paths: Record = { + 'top-left': 'M5 11V5h6', + 'top-right': 'M13 11V5H7', + 'bottom-left': 'M5 9v6h6', + 'bottom-right': 'M13 9v6H7', + }; + return ( + + ); +} + const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) => COLUMN_MODES.map((x) => ({ id: `${y}-${x}`, @@ -74,10 +118,6 @@ const FIELD_HINTS = { '锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。', anchor_max: '锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。', - offset_min: - '偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。', - offset_max: - '偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。', } as const; function cloneTransform(transform: Transform): Transform { @@ -166,9 +206,44 @@ function formatValue(value: number): string { return String(Number(value.toFixed(2))); } +function cornerFields(corner: Corner): { + x: 'offset_min' | 'offset_max'; + y: 'offset_min' | 'offset_max'; +} { + switch (corner) { + case 'top-left': + return { x: 'offset_min', y: 'offset_min' }; + case 'top-right': + return { x: 'offset_max', y: 'offset_min' }; + case 'bottom-left': + return { x: 'offset_min', y: 'offset_max' }; + case 'bottom-right': + return { x: 'offset_max', y: 'offset_max' }; + } +} + +function cornerValues(transform: Transform, corner: Corner): [number, number] { + const fields = cornerFields(corner); + return [transform[fields.x][0], transform[fields.y][1]]; +} + +function updateCorner( + transform: Transform, + corner: Corner, + axis: Axis, + value: number, +): Transform { + const fields = cornerFields(corner); + const next = cloneTransform(transform); + next[axis === 0 ? fields.x : fields.y][axis] = value; + return next; +} + function VectorInputRow({ label, hint, + hideLabel = false, + stacked = false, values, step, readOnly, @@ -176,24 +251,45 @@ function VectorInputRow({ }: { label: string; hint: string; + hideLabel?: boolean; + stacked?: boolean; values: readonly [number, number]; step: number; readOnly: boolean; onCommit: (axis: Axis, value: number) => void; }) { + const fields = AXIS_LABELS.map((axisLabel, axis) => ( + onCommit(axis as Axis, value)} + /> + )); + + if (stacked) { + return ( +
+ {hideLabel ? ( + {label} + ) : ( + + )} +
{fields}
+
+ ); + } + return (
- - {AXIS_LABELS.map((axisLabel, axis) => ( - onCommit(axis as Axis, value)} - /> - ))} + {hideLabel ? ( + {label} + ) : ( + + )} + {fields}
); } @@ -201,7 +297,7 @@ function VectorInputRow({ function FieldLabel({ label, hint }: { label: string; hint: string }) { return ( {label} @@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) { > ); } @@ -320,12 +410,105 @@ function ScalarInput({ ); } +function DirectionButton({ + ariaLabel, + disabled, + onAdjust, + children, +}: { + ariaLabel: string; + disabled: boolean; + onAdjust: (step: number) => void; + children: ReactNode; +}) { + const repeatTimeout = useRef | null>(null); + const repeatInterval = useRef | null>(null); + const repeated = useRef(false); + + const stopRepeating = () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + + const startRepeating = (multiplier: number) => { + if (disabled) { + return; + } + repeated.current = false; + repeatTimeout.current = setTimeout(() => { + repeated.current = true; + onAdjust(multiplier); + repeatInterval.current = setInterval(() => onAdjust(multiplier), 70); + }, 350); + }; + + useEffect(() => { + return () => { + if (repeatTimeout.current) { + clearTimeout(repeatTimeout.current); + repeatTimeout.current = null; + } + if (repeatInterval.current) { + clearInterval(repeatInterval.current); + repeatInterval.current = null; + } + }; + }, []); + + return ( + + ); +} + export function TransformEditor({ transform, parentSize, readOnly = false, onChange, }: TransformEditorProps) { + const [selectedCorner, setSelectedCorner] = useState('top-left'); + const transformRef = useRef(transform); + const selectedCornerRef = useRef(selectedCorner); + useEffect(() => { + transformRef.current = transform; + selectedCornerRef.current = selectedCorner; + }, [transform, selectedCorner]); const [presetOpen, setPresetOpen] = useState(false); const [customOpen, setCustomOpen] = useState( () => findPreset(transform).id === CUSTOM_PRESET.id, @@ -369,6 +552,24 @@ export function TransformEditor({ transform.anchor_min[1] > transform.anchor_max[1] || geometry?.invalid; + const selectedCornerLabel = + CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角'; + const selectedCornerValues = cornerValues(transform, selectedCorner); + const adjustSelectedCorner = ( + axis: Axis, + direction: -1 | 1, + multiplier = 1, + ) => { + if (readOnly) { + return; + } + const currentTransform = transformRef.current; + const currentCorner = selectedCornerRef.current; + const current = cornerValues(currentTransform, currentCorner)[axis]; + const next = Number((current + direction * multiplier).toFixed(4)); + onChange(updateCorner(currentTransform, currentCorner, axis, next)); + }; + return (
- updateVector('offset_min', axis, value)} - /> - updateVector('offset_max', axis, value)} + + +
+
+ {CORNERS.map((corner) => { + const active = corner.id === selectedCorner; + return ( + + ); + })} +
+ +
+
+ + + adjustSelectedCorner(1, -1, multiplier * 1) + } + > + + + + adjustSelectedCorner(0, -1, multiplier * 1) + } + > + +
+ +
+ + adjustSelectedCorner(0, 1, multiplier * 1) + } + > + + + + adjustSelectedCorner(1, 1, multiplier * 1) + } + > + + +
+ + + onChange(updateCorner(transform, selectedCorner, axis, value)) + } + /> +
+
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..75a1efce3 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,11 @@ export function PreviewWorkspace({ logicalSize, spaceHeld, tree, + keepChildrenUnchanged: canvas.keepChildrenUnchanged, viewportRef, + onPreviewTransform: updatePreviewTransform, + previewRef: viewportElementRef, + selectedNodeId: canvas.selectedNodeId, }); const { onNodePointerDown, @@ -96,6 +120,9 @@ export function PreviewWorkspace({ onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodePointerCancel, + onNodeResizePointerCancel, + consumeNodeClick, } = nodeInteractions; const setViewport = useCallback((next: CanvasViewport) => { @@ -382,6 +409,7 @@ export function PreviewWorkspace({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={canvas.hiddenNodeIds} + previewTransforms={previewTransforms} selectedNodeId={canvas.selectedNodeId} resources={{ previewUrls, @@ -389,13 +417,16 @@ export function PreviewWorkspace({ fontFaces: canvas.fontFaces, }} onSelectNode={canvas.selectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={handleNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={canvas.selectExclusiveChild} viewportScale={viewport.scale} /> 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..34f624152 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,9 +32,11 @@ type UiTreeRendererProps = { renderMode: UiEditorRenderMode; showFrame: boolean; hiddenNodeIds: ReadonlySet; + previewTransforms?: ReadonlyMap; selectedNodeId: NodeId | null; resources: PreviewComponentResources; onSelectNode: (id: NodeId) => void; + consumeNodeClick?: () => boolean; onNodeContextMenu: ( event: ReactMouseEvent, node: UiNode, @@ -43,6 +45,7 @@ type UiTreeRendererProps = { onNodePointerDown: NodePointerDown; onNodePointerMove: (event: ReactPointerEvent) => void; onNodePointerUp: (event: ReactPointerEvent) => void; + onNodePointerCancel: (event: ReactPointerEvent) => void; onNodeResizePointerDown: ( event: ReactPointerEvent, node: UiNode, @@ -50,6 +53,7 @@ type UiTreeRendererProps = { ) => void; onNodeResizePointerMove: (event: ReactPointerEvent) => void; onNodeResizePointerUp: (event: ReactPointerEvent) => void; + onNodeResizePointerCancel: (event: ReactPointerEvent) => void; onSelectExclusiveChild: (nodeId: NodeId) => void; viewportScale: number; }; @@ -70,6 +74,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,16 +86,20 @@ function RenderNode({ renderMode, showFrame, hiddenNodeIds, + previewTransforms, selectedNodeId, resources, onSelectNode, + consumeNodeClick = () => false, onNodeContextMenu, onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp, + onNodeResizePointerCancel, onSelectExclusiveChild, viewportScale, }: Omit & { @@ -94,14 +107,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; @@ -109,6 +124,8 @@ function RenderNode({ const isEditorOverlay = renderMode === 'editor-overlay'; const isFrameVisible = isEditorOverlay || showFrame; + const receivesPointerGesture = parentContainer === undefined; + const hasDirectPointerGesture = receivesPointerGesture && !isRoot; const exclusiveVisibleChildId = node.children_display_mode === 'Exclusive' ? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds) @@ -116,11 +133,11 @@ function RenderNode({ return (
{ - event.stopPropagation(); - onSelectNode(node.id); - }} + onClick={ + hasDirectPointerGesture + ? (event) => { + event.stopPropagation(); + consumeNodeClick(); + } + : (event) => { + event.stopPropagation(); + if (consumeNodeClick()) return; + onSelectNode(node.id); + } + } onContextMenu={(event) => { if (!isEditorOverlay) return; event.preventDefault(); @@ -149,11 +174,13 @@ function RenderNode({ onNodeContextMenu(event, node, Boolean(isRoot)); }} onPointerDown={ - parentContainer ? undefined : (event) => onNodePointerDown(event, node) + receivesPointerGesture + ? (event) => onNodePointerDown(event, node) + : undefined } - onPointerMove={parentContainer ? undefined : onNodePointerMove} - onPointerUp={parentContainer ? undefined : onNodePointerUp} - onPointerCancel={parentContainer ? undefined : onNodePointerUp} + onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined} + onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined} + onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined} title={isFrameVisible ? node.metadata.name || undefined : undefined} > {isFrameVisible && node.metadata.name ? ( @@ -189,16 +216,20 @@ function RenderNode({ renderMode={renderMode} showFrame={showFrame} hiddenNodeIds={hiddenNodeIds} + previewTransforms={activePreviewTransforms} selectedNodeId={selectedNodeId} resources={resources} onSelectNode={onSelectNode} + consumeNodeClick={consumeNodeClick} onNodeContextMenu={onNodeContextMenu} onNodePointerDown={onNodePointerDown} onNodePointerMove={onNodePointerMove} onNodePointerUp={onNodePointerUp} + onNodePointerCancel={onNodePointerCancel} onNodeResizePointerDown={onNodeResizePointerDown} onNodeResizePointerMove={onNodeResizePointerMove} onNodeResizePointerUp={onNodeResizePointerUp} + onNodeResizePointerCancel={onNodeResizePointerCancel} onSelectExclusiveChild={onSelectExclusiveChild} viewportScale={viewportScale} /> @@ -228,7 +259,7 @@ function RenderNode({ } onPointerMove={onNodeResizePointerMove} onPointerUp={onNodeResizePointerUp} - onPointerCancel={onNodeResizePointerUp} + onPointerCancel={onNodeResizePointerCancel} /> )) : null} 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..9b79de2d7 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'; @@ -28,7 +31,10 @@ type GestureBase = { startClientX: number; startClientY: number; startTransform: UiNode['layout']['transform']; + hitNodeId: string; hasMoved: boolean; + pendingTransform?: UiNode['layout']['transform']; + previewNodeIds: string[]; }; type ActiveGesture = @@ -78,31 +84,151 @@ function releasePointer(target: HTMLDivElement, pointerId: number) { } } +function findNodeById(node: UiNode, nodeId: string): UiNode | null { + if (node.id === nodeId) return node; + for (const child of node.children) { + const match = findNodeById(child, nodeId); + if (match) return match; + } + return null; +} + +function resolveHitNodeId( + event: ReactPointerEvent, + fallback: string, +) { + const target = event.target; + if (!(target instanceof Element)) return fallback; + return ( + target.closest('[data-node-id]')?.dataset.nodeId ?? fallback + ); +} + +function isInsidePreview( + event: ReactPointerEvent, + previewRef: RefObject | undefined, +) { + const preview = previewRef?.current; + if (!preview) return true; + const rect = preview.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); +} + +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, + previewRef, + selectedNodeId, }: { 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; + previewRef?: RefObject; + selectedNodeId?: string | null; }) { const activeGestureRef = useRef(null); + // Keep cleanup stable while still invoking the latest preview callback. + const onPreviewTransformRef = useRef(onPreviewTransform); + onPreviewTransformRef.current = onPreviewTransform; + const suppressNextNodeClickRef = useRef(false); 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; + suppressNextNodeClickRef.current = false; }, []); useEffect(() => cancelGesture, [cancelGesture]); + useEffect(() => { + window.addEventListener('blur', cancelGesture); + return () => window.removeEventListener('blur', cancelGesture); + }, [cancelGesture]); + useEffect(() => { const gesture = activeGestureRef.current; if ( @@ -138,22 +264,42 @@ export function useNodeTransformInteraction({ ) { return; } + const selectedNode = + selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null; + const dragNode = + selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node; + if (!isFiniteTransform(dragNode.layout.transform)) return; event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; + event.preventDefault(); activeGestureRef.current = { kind: 'drag', treeId: activeImageId, - nodeId: node.id, + nodeId: dragNode.id, pointerId: event.pointerId, target: event.currentTarget, startClientX: event.clientX, startClientY: event.clientY, - startTransform: structuredClone(node.layout.transform), + startTransform: structuredClone(dragNode.layout.transform), + hitNodeId: resolveHitNodeId(event, node.id), hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + dragNode.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, spaceHeld, tree?.root.id], + [ + activeImageId, + keepChildrenUnchanged, + logicalSize, + spaceHeld, + selectedNodeId, + tree, + ], ); const onNodePointerMove = useCallback( @@ -176,6 +322,7 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; + suppressNextNodeClickRef.current = true; const nextTransform = structuredClone(gesture.startTransform); nextTransform.offset_min[0] += logicalDeltaX; nextTransform.offset_min[1] += logicalDeltaY; @@ -185,12 +332,50 @@ 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 (!gesture) return; + const cleanPointerUpInside = + !gesture?.hasMoved && isInsidePreview(event, previewRef); + if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId); + if (gesture.hasMoved && gesture.pendingTransform) { + canvas.updateNodeTransform( + gesture.treeId, + gesture.nodeId, + gesture.pendingTransform, + ); + } + cancelGesture(); + if (gesture.hasMoved || cleanPointerUpInside) + suppressNextNodeClickRef.current = true; + }, + [acceptsGestureEvent, cancelGesture, canvas, previewRef], + ); + + const onNodePointerCancel = useCallback( (event: ReactPointerEvent) => { if (!acceptsGestureEvent(event)) return; event.stopPropagation(); @@ -236,7 +421,7 @@ export function useNodeTransformInteraction({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - canvas.selectNode(node.id); + suppressNextNodeClickRef.current = false; activeGestureRef.current = { kind: 'resize', treeId: activeImageId, @@ -247,13 +432,20 @@ export function useNodeTransformInteraction({ startClientX: event.clientX, startClientY: event.clientY, startTransform: structuredClone(node.layout.transform), + hitNodeId: node.id, startRect: context.rect, parentRect: context.parentRect, ratioAxis: null, hasMoved: false, + previewNodeIds: previewNodeIds( + tree, + node.id, + logicalSize, + keepChildrenUnchanged, + ), }; }, - [activeImageId, canvas, logicalSize, spaceHeld, tree], + [activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree], ); const onNodeResizePointerMove = useCallback( @@ -310,17 +502,40 @@ export function useNodeTransformInteraction({ return; } gesture.hasMoved = true; - canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform); + suppressNextNodeClickRef.current = true; + 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 { + consumeNodeClick: useCallback(() => { + if (!suppressNextNodeClickRef.current) return false; + suppressNextNodeClickRef.current = false; + return true; + }, []), onNodePointerDown, onNodePointerMove, onNodePointerUp, + onNodePointerCancel, onNodeResizePointerDown, onNodeResizePointerMove, onNodeResizePointerUp: onNodePointerUp, + onNodeResizePointerCancel: onNodePointerCancel, }; } 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/TransformEditor.test.tsx b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx new file mode 100644 index 000000000..9145db48b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/TransformEditor.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Transform } from '../src/features/ui-editor/types/Transform'; +import { TransformEditor } from '../src/view/ui-editor/components/Inspector/Transform/TransformEditor'; + +const transform: Transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [10, 20], + offset_max: [30, 40], +}; + +function firePointerDown(element: HTMLElement, button: number) { + const event = new Event('pointerdown', { bubbles: true }); + Object.defineProperty(event, 'button', { value: button }); + fireEvent(element, event); +} + +function renderEditor(onChange = vi.fn()) { + return { + onChange, + ...render(), + }; +} + +describe('TransformEditor corner offset controls', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses one X/Y input pair for the selected corner', () => { + renderEditor(); + + expect( + screen.getByRole('spinbutton', { name: '位置微调 X' }), + ).toHaveProperty('value', '10'); + expect( + screen.getByRole('spinbutton', { name: '位置微调 Y' }), + ).toHaveProperty('value', '20'); + expect(screen.getAllByRole('spinbutton')).toHaveLength(2); + + fireEvent.click(screen.getByRole('button', { name: '右下角' })); + + expect( + screen.getByRole('spinbutton', { name: '位置微调 X' }), + ).toHaveProperty('value', '30'); + expect( + screen.getByRole('spinbutton', { name: '位置微调 Y' }), + ).toHaveProperty('value', '40'); + }); + + it('writes the selected corner input back to the matching offset component', () => { + const { onChange } = renderEditor(); + + fireEvent.click(screen.getByRole('button', { name: '右上角' })); + const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' }); + fireEvent.change(xInput, { target: { value: '55' } }); + fireEvent.blur(xInput); + + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [55, 40], + }); + }); + + it('moves only the selected corner with direction keys', () => { + const { onChange } = renderEditor(); + + fireEvent.click(screen.getByRole('button', { name: '右下角' })); + fireEvent.click(screen.getByRole('button', { name: '右下角向左' })); + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [29, 40], + }); + + fireEvent.click(screen.getByRole('button', { name: '右下角向上' }), { + shiftKey: true, + }); + expect(onChange).toHaveBeenLastCalledWith({ + ...transform, + offset_max: [30, 30], + }); + }); + + it('allows a click adjustment after a cancelled long press', () => { + vi.useFakeTimers(); + const { onChange } = renderEditor(); + const button = screen.getByRole('button', { name: '左上角向右' }); + + firePointerDown(button, 0); + act(() => { + vi.advanceTimersByTime(350); + }); + fireEvent.pointerCancel(button); + fireEvent.click(button); + + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('does not start repeating for non-primary pointer buttons', () => { + vi.useFakeTimers(); + const { onChange } = renderEditor(); + const button = screen.getByRole('button', { name: '左上角向右' }); + + firePointerDown(button, 2); + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); 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 aadd02977..5ab564020 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2314,303 +2314,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/imageAssetSelector.test.tsx b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx new file mode 100644 index 000000000..a012f2d60 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx @@ -0,0 +1,161 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector'; + +const sprites = { + hero: { + asset_id: 'hero', + metadata: { name: '主角', asset_type: 'sprite' }, + path: 'assets/hero.png', + pixel_size: [32, 32] as [number, number], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }, + enemy: { + asset_id: 'enemy', + metadata: { name: '敌人', asset_type: 'sprite' }, + path: 'assets/enemy.png', + pixel_size: [32, 32] as [number, number], + pixels_per_unit: 1, + border: { left: 0, right: 0, top: 0, bottom: 0 }, + }, +}; + +function renderSelector( + value: string | null = null, + onChange = vi.fn(), + previewUrls: Record = {}, + selectorSprites: typeof sprites = sprites, +) { + return render( + , + ); +} + +describe('ImageAssetSelector', () => { + afterEach(() => cleanup()); + + it('selects a sprite and clears the selection from the modal', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + expect(screen.getByRole('dialog', { name: '选择图片素材' })).toBeTruthy(); + await user.click(screen.getByRole('button', { name: /主角/ })); + expect(onChange).toHaveBeenCalledWith('hero'); + expect(screen.queryByRole('dialog')).toBeNull(); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + await user.click(screen.getByRole('button', { name: /清除选择/ })); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('shows preview images, placeholders, and invalid references', async () => { + const user = userEvent.setup(); + renderSelector('missing', vi.fn(), { hero: 'data:image/png;base64,abc' }); + + expect(screen.getByText('素材不存在(missing)')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '更换素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByAltText('主角')).toBeTruthy(); + expect(within(dialog).getByText('素材不存在')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); + + it('cannot open while read only', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '更换素材' })).toHaveProperty( + 'disabled', + true, + ); + }); + + it('retries a preview when its URL is regenerated', async () => { + const user = userEvent.setup(); + const view = renderSelector(null, vi.fn(), { + hero: 'data:image/png;base64,first', + }); + + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + const image = within(dialog).getByAltText('主角'); + fireEvent.error(image); + expect(within(dialog).queryByAltText('主角')).toBeNull(); + + view.rerender( + , + ); + + expect( + within(screen.getByRole('dialog', { name: '选择图片素材' })).getByAltText( + '主角', + ), + ).toBeTruthy(); + }); + + it('supports keyboard activation for sprite cards', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSelector(null, onChange); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const hero = screen.getByRole('button', { name: /主角/ }); + hero.focus(); + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith('hero'); + }); + + it('closes when the modal backdrop is clicked', async () => { + const user = userEvent.setup(); + renderSelector(); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + const overlay = dialog.parentElement; + expect(overlay).toBeTruthy(); + await user.click(overlay!); + expect(screen.queryByRole('dialog', { name: '选择图片素材' })).toBeNull(); + }); + + it('shows an empty state when no sprites are available', async () => { + const user = userEvent.setup(); + renderSelector(null, vi.fn(), {}, {}); + await user.click(screen.getByRole('button', { name: '选择素材' })); + const dialog = screen.getByRole('dialog', { name: '选择图片素材' }); + expect(within(dialog).getByText('暂无可用素材')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /清除选择/ }), + ).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 5b47a87c3..6f79be210 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; import type { UITree } from '../src/features/ui-editor/types/UITree'; @@ -9,6 +9,10 @@ import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/ import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer'; import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu'; +afterEach(() => { + cleanup(); +}); + function node(id: string, children: UiNode[] = []): UiNode { return { id, @@ -62,6 +66,7 @@ function renderTree( options: { showFrame?: boolean; selectedNodeId?: string | null; + onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; } = {}, ) { @@ -73,20 +78,41 @@ function renderTree( hiddenNodeIds={hiddenNodeIds} selectedNodeId={options.selectedNodeId ?? null} resources={resources} - onSelectNode={vi.fn()} + onSelectNode={options.onSelectNode ?? vi.fn()} onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()} onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); } describe('UI tree preview visibility', () => { + it('disables native text selection on preview node layers', () => { + const rendered = renderTree('editor-overlay', new Set()); + + expect( + rendered.container + .querySelector('[data-node-id="child"]') + ?.classList.contains('select-none'), + ).toBe(true); + }); + + it('does not use click alone to select directly draggable nodes', () => { + const onSelectNode = vi.fn(); + const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + fireEvent.click( + rendered.container.querySelector('[data-node-id="child"]')!, + ); + expect(onSelectNode).not.toHaveBeenCalled(); + }); + it('shares node menu restrictions, disabled actions, and dismissal behavior', () => { const onClose = vi.fn(); const onInsertChild = vi.fn(); @@ -150,9 +176,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -227,9 +255,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); @@ -299,9 +329,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={onNodePointerDown} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} onSelectExclusiveChild={onSelectChild} viewportScale={1} />, @@ -331,9 +363,11 @@ describe('UI tree preview visibility', () => { onNodePointerDown={vi.fn()} onNodePointerMove={vi.fn()} onNodePointerUp={vi.fn()} + onNodePointerCancel={vi.fn()} onNodeResizePointerDown={vi.fn()} onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} + onNodeResizePointerCancel={vi.fn()} viewportScale={1} />, ); 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..7434c355e 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, { @@ -59,12 +70,14 @@ function pointerEvent( pointerId: number, clientX: number, clientY: number, + eventTarget: EventTarget = target, ) { return { button: 0, clientX, clientY, currentTarget: target, + target: eventTarget, pointerId, preventDefault: vi.fn(), shiftKey: false, @@ -83,10 +96,19 @@ function renderInteraction({ activeImageId = 'page', scale = 1, tree = pageTree, + keepChildrenUnchanged = false, + onPreviewTransform, + selectedNodeId = null, }: { activeImageId?: string | null; scale?: number; tree?: UITree | null; + keepChildrenUnchanged?: boolean; + onPreviewTransform?: ( + nodeId: string, + transform: UiNode['layout']['transform'] | null, + ) => void; + selectedNodeId?: string | null; } = {}) { const canvasProjection = canvas(); const viewportRef = { current: { scale } }; @@ -99,6 +121,9 @@ function renderInteraction({ spaceHeld: false, tree: currentTree, viewportRef, + keepChildrenUnchanged, + onPreviewTransform, + selectedNodeId, }), { initialProps: { imageId: activeImageId, currentTree: tree } }, ); @@ -106,6 +131,186 @@ function renderInteraction({ } describe('useNodeTransformInteraction', () => { + it('moves the current selection when dragging another node without selecting the hit node', () => { + const selected = transformedNode('selected', { + anchor_min: [0, 0], + anchor_max: [0, 0], + offset_min: [20, 20], + offset_max: [80, 70], + }); + const tree: UITree = { + src_ui_design: 'page', + root: node('root', [selected, child]), + }; + const { result, canvasProjection } = renderInteraction({ + tree, + selectedNodeId: 'selected', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith( + 'page', + 'selected', + expect.objectContaining({ + offset_min: [24, 20], + offset_max: [84, 70], + }), + ); + }); + + it('prevents native text selection when starting a node drag', () => { + const { result } = renderInteraction(); + const target = gestureTarget(); + const event = pointerEvent(target, 1, 0, 0); + + act(() => { + result.current.onNodePointerDown(event, child); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('cleans preview transforms for the selected parent when dragging from a child', () => { + 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, + selectedNodeId: 'parent', + }); + const target = gestureTarget(); + + act(() => { + result.current.onNodePointerDown( + pointerEvent(target, 1, 0, 0), + nestedChild, + ); + result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5)); + result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5)); + }); + + expect(onPreviewTransform).toHaveBeenCalledWith('parent', null); + expect(onPreviewTransform).toHaveBeenCalledWith('child', null); + }); + + 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 +330,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,9 +340,44 @@ describe('useNodeTransformInteraction', () => { 'se', ); }); + expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1); expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2); }); + it('defers selection until a clean pointerup and suppresses follow-up click', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + act(() => { + result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1)); + }); + expect(canvasProjection.selectNode).toHaveBeenCalledWith('child'); + expect(result.current.consumeNodeClick()).toBe(true); + expect(result.current.consumeNodeClick()).toBe(false); + }); + + it('does not select after a moved gesture or pointer cancellation', () => { + const { result, canvasProjection } = renderInteraction(); + const target = gestureTarget(); + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child); + result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0)); + result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(true); + + act(() => { + result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child); + result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0)); + }); + expect(canvasProjection.selectNode).not.toHaveBeenCalled(); + expect(result.current.consumeNodeClick()).toBe(false); + }); + it('cancels a gesture when its tree changes and ignores its later events', () => { const { result, rerender, canvasProjection } = renderInteraction(); const target = gestureTarget(); diff --git a/docs/README.md b/docs/README.md index 0d4c24053..d44e466ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,8 +30,10 @@ - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md) - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) +- [UI 编辑器变换角点偏移编辑器](./technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.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 aeb93530e..8c509f624 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7612,6 +7612,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。 @@ -7937,6 +7938,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/【前端架构】UI编辑会话模块边界-2026-08-19.md b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md index 6e33010bd..12460d0e0 100644 --- a/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md +++ b/docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md @@ -15,6 +15,8 @@ 预览树在会话边界归一化为 `UITree | null`:尚未选择界面图、正在加载或尚未识别树时均以 `null` 表示,不能把 `Array.find` 的 `undefined` 传播到视图接口。 +素材缩略图的加载失败状态按“素材 ID + 预览 URL”记录;同一 URL 失败后显示占位图,资源重新加载或重新导入产生新 URL 时必须允许再次尝试,不能把失败永久绑定到素材 ID。 + 保存与代码生成共享同一份持久化 State/revision。会话层在保存或生成进行期间互斥拦截,且代码生成必须基于已加载的持久化 revision;视图层的保存按钮和“保存并返回”按钮同步遵守该互斥状态。 `UiDesignStateStore` 的 `generateCode(assetId)` 是必需能力,返回成功结果时不得为 nullable;所有注入的 adapter 与测试替身都必须实现该方法。 diff --git a/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md new file mode 100644 index 000000000..f2b926c74 --- /dev/null +++ b/docs/technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md @@ -0,0 +1,41 @@ +# UI 编辑器图片素材选择器 + +> 更新时间:`2026-09-03` + +## 目标 + +在 UI 编辑器 Inspector 的图片组件中,用独立 modal 图片选择器替换原生素材下拉框,使用户绑定素材时可以看到现有预览图或缩略图。 + +## 数据来源与边界 + +- 素材列表继续使用 Inspector 当前的 `sprites`,顺序沿用 `Object.entries(sprites)`。 +- `SpriteAsset` 不新增缩略图字段。预览继续消费 UI 编辑会话已有的 `previewUrls: Record`;预览缺失不影响素材绑定。 +- `previewUrls` 由 Inspector projection 沿 `NodeInspector → ComponentPanel → ImagePanel` 传入选择器。 +- 本次不新增后端接口、持久化字段、搜索筛选或二级大图预览。 + +## 交互合同 + +### Inspector 触发区域 + +- 正常绑定显示素材名称。 +- 空值显示 `未绑定`。 +- 引用不存在的素材显示 `素材不存在()`,保留原 ID,不在打开面板时自动清除。 +- 未绑定时按钮文案为 `选择素材`;已有绑定或失效引用时为 `更换素材`。 +- `readOnly` 时按钮禁用,不能打开选择器。 + +### 素材选择 modal + +- 使用现有 `ThemedModal`,带遮罩、关闭按钮、Esc 关闭和点击遮罩关闭。 +- modal 内容使用纵向 flex;素材区域占据剩余空间并内部滚动,modal 设置最大高度。 +- 素材以弹性网格展示,卡片包含预览图和素材名称,图片使用 `object-contain`。 +- 有预览时显示 `previewUrls[id]`;无预览或加载失败时显示棋盘格背景与 `ImageIcon` 占位,素材仍可选。 +- 当前绑定项显示选中边框或标记。 +- 第一项为明确的“清除选择”,点击立即写入 `null` 并关闭。 +- 点击任意正常素材立即写入其 ID 并关闭;重复点击当前素材也按相同流程处理。 +- 当前失效引用保留为禁用信息卡,显示 ID;用户仍可使用“清除选择”。 +- 素材为空时显示 `暂无可用素材`,保留关闭操作。 +- 卡片支持 Tab 聚焦以及 Enter/Space 选择;关闭后焦点返回触发按钮。 + +## 验收 + +自动化测试覆盖:打开/关闭、按钮和名称状态、当前项高亮、选择 ID、清除 `null`、缺预览占位、失效引用、空列表、`readOnly` 禁止打开、键盘选择和遮罩关闭。实现后运行定向测试、前端类型检查、编码检查及 `git diff --check`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 08ac8f168..0e195b066 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/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md index f9852c817..ec137834f 100644 --- a/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md +++ b/docs/technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md @@ -1,6 +1,6 @@ # UI 编辑器 Godot 容器布局模型 -更新时间:`2026-08-18` +更新时间:`2026-09-03` ## 范围 @@ -40,3 +40,9 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa ## 编辑器交互 布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。 + +Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。 + +取消、失焦、树切换或无效几何导致的手势终止会同时清理下一次 click 抑制状态;只有正常 pointerup 才会根据拖动结果抑制后续 click。 + +预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。 diff --git a/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md new file mode 100644 index 000000000..3ffc54549 --- /dev/null +++ b/docs/technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md @@ -0,0 +1,60 @@ +# UI 编辑器变换角点偏移编辑器 + +更新时间:`2026-09-03` + +## 目标 + +将 UI 编辑器 Inspector 中的 offset 编辑从两行 `offset_min/offset_max` 改为四角选择式编辑:界面展示左上、右上、左下、右下四个角,用户先选择一个角,再使用唯一的一组 X/Y 数字输入和四个方向键调整该角。 + +本次只改变编辑器表现和交互,不改变 `Transform` 数据结构、持久化格式或后端契约。 + +## 数据映射 + +四角是现有两个 offset 向量的投影,不是四组独立数据: + +| 选中角 | X 输入写入 | Y 输入写入 | +| ------ | --------------- | --------------- | +| 左上 | `offset_min[0]` | `offset_min[1]` | +| 右上 | `offset_max[0]` | `offset_min[1]` | +| 左下 | `offset_min[0]` | `offset_max[1]` | +| 右下 | `offset_max[0]` | `offset_max[1]` | + +输入框始终显示对应 `Transform` 的原始 offset 值,保留现有正负号;不转换成 CSS `right`/`bottom` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。 + +## 交互 + +- 组件内部维护当前选中角,首次挂载时默认左上;切换角点由用户控制,编辑过程中不会因 offset 更新而跳回其他角。 +- 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。 +- 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。 +- 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。 +- 方向键长按只响应主指针;指针取消或离开时清理长按抑制状态,正常释放仍抑制尾随点击。 +- 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。 +- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。 +- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。 + +## 视觉约束 + +- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。 +- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。 +- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label` 和 `title` 提供。 +- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。 +- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。 +- 角点选择器和方向键的可点击尺寸不小于 28px。 +- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。 +- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。 +- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。 +- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。 +- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。 + +## 非目标 + +- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。 +- 不提供四角独立状态,也不提供整体平移快捷键。 +- 不增加移动端专属布局或移动端交互。 + +## 验收 + +1. 四角选择能正确驱动唯一一组 X/Y 输入,并按数据映射表写回 `offset_min/offset_max`。 +2. 四方向键只修改当前选中角对应分量,步长、Shift 加速、长按行为可用。 +3. 输入提交、Escape 回滚、非法值、只读状态和现有 warning 行为不回归。 +4. `Transform` 类型、持久化数据和现有锚点编辑行为保持不变。 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。