From 4a3ff531fbae3710a7797487cfa43a47ea74ef8a Mon Sep 17 00:00:00 2001 From: menghao Date: Wed, 5 Aug 2026 12:48:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E5=AE=9E=E6=97=B6=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E6=9B=B4=E6=96=B0=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=84=A6=E7=82=B9=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过统一 Runtime emitter 和受令牌保护的 Runner relay 触发 manifest 重读 为 manifest 重读增加事件合并、项目隔离和迟到响应保护 按稳定资源 ID 重构详情焦点恢复与资源删除 fallback 补充真实 App 事件链、乱序项目切换、媒体焦点和 Rust relay 回归测试 同步更新工作台技术方案、PRD 与共享项目记忆 --- .../src-tauri/src/agent/runtime_driver.rs | 15 +- .../src/agent/runtime_driver/entrypoints.rs | 141 +++++++- .../src-tauri/src/main.rs | 27 +- .../src-tauri/src/runner/client.rs | 23 +- .../src-tauri/src/runner/dispatch.rs | 20 +- .../src-tauri/src/runner/protocol.rs | 6 +- .../src-tauri/src/runner/tests.rs | 9 +- .../src-tauri/src/tests/mod.rs | 43 +++ apps/ai-game-creator-shell/src/App.tsx | 137 ++++++-- apps/ai-game-creator-shell/src/app/types.ts | 6 + .../src/view/project-development/index.tsx | 163 ++++++--- .../tests/appSurface/harness.ts | 25 ++ .../tests/appSurface/home.suite.ts | 317 ++++++++++++++++++ .../appSurface/project-development.suite.ts | 146 +++++++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 7 +- .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 14 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 +- 18 files changed, 1014 insertions(+), 97 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index bb36a99d4..7a03a5eed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,6 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< + std::sync::Mutex>, +> = OnceLock::new(); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -234,15 +237,21 @@ pub(in crate::agent) use recovery_scan::*; pub(in crate::agent) use task_queue::*; pub(in crate::agent) use task_start::*; +#[cfg(test)] +pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, chat_with_game_creator_role_agent_for_session_at, chat_with_game_creator_role_agent_runtime_at, chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, - chat_with_game_creator_role_agent_stream_for_session_at, generate_local_game_draft_at, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + chat_with_game_creator_role_agent_stream_for_session_at, + configure_game_creator_manifest_invalidation_event_sink, + emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, + generate_local_game_draft_at, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, + start_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; 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 29e951e8d..04b6afc71 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 @@ -1,30 +1,145 @@ use super::*; +const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; + +fn lock_game_creator_manifest_invalidation_event_sink( +) -> std::sync::MutexGuard<'static, Option> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK + .get_or_init(|| Mutex::new(None)) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); } -pub(in crate::agent) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { +pub(crate) fn start_game_creator_manifest_invalidation_event_sink( + app: tauri::AppHandle, +) -> Result { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .map_err(|error| format!("绑定 manifest 失效事件接收端失败:{error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("读取 manifest 失效事件接收端失败:{error}"))? + .port(); + let token = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let expected_token = token.clone(); + thread::Builder::new() + .name("manifest-invalidation-event-sink".to_string()) + .spawn(move || { + for incoming in listener.incoming() { + let Ok(mut stream) = incoming else { + continue; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let mut payload = Vec::new(); + let mut limited = + (&mut stream).take(GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + 1); + if limited.read_to_end(&mut payload).is_err() + || payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES + { + continue; + } + let Ok(envelope) = serde_json::from_slice::< + GameCreatorManifestInvalidationRelayEnvelope, + >(&payload) else { + continue; + }; + if envelope.token != expected_token { + continue; + } + let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + } + }) + .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; + Ok(GameCreatorManifestInvalidationEventSink { port, token }) +} + +pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( + port: u16, + token: &str, +) -> Result<(), String> { + if port == 0 { + return Err("manifest 失效事件接收端口无效".to_string()); + } + let token = token.trim(); + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("manifest 失效事件接收令牌无效".to_string()); + } + *lock_game_creator_manifest_invalidation_event_sink() = + Some(GameCreatorManifestInvalidationEventSink { + port, + token: token.to_string(), + }); + Ok(()) +} + +#[cfg(test)] +pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() { + *lock_game_creator_manifest_invalidation_event_sink() = None; +} + +fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { + let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); + let Some(sink) = sink else { + return Ok(()); + }; + let envelope = GameCreatorManifestInvalidationRelayEnvelope { + token: sink.token, + event: GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), + }, + }; + let payload = serde_json::to_vec(&envelope) + .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; + if payload.len() as u64 > GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES { + return Err("manifest 失效事件超过大小上限".to_string()); + } + let address = std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, sink.port).into(); + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(100)) + .map_err(|error| format!("连接 manifest 失效事件接收端失败:{error}"))?; + stream + .set_write_timeout(Some(Duration::from_millis(100))) + .map_err(|error| format!("配置 manifest 失效事件发送超时失败:{error}"))?; + stream + .write_all(&payload) + .map_err(|error| format!("发送 manifest 失效事件失败:{error}")) +} + +pub(crate) fn game_creator_agent_runtime_update_event( + root: &Path, + runtime: AgentRuntimeResult, +) -> GameCreatorAgentRuntimeUpdateEvent { + GameCreatorAgentRuntimeUpdateEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: runtime.state.agent_id.clone(), + run_id: runtime.state.run_id.clone(), + status: runtime.state.status.clone(), + phase: runtime.state.phase.clone(), + manifest_invalidated: true, + runtime, + } +} + +pub(crate) fn emit_game_creator_agent_runtime_update(root: &Path, agent_id: &str) { + if GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get().is_none() { + let _ = relay_game_creator_manifest_invalidation(root, agent_id); + } let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; let Ok(runtime) = read_game_creator_agent_runtime_at(root, agent_id) else { return; }; - let agent_id = runtime.state.agent_id.clone(); - let run_id = runtime.state.run_id.clone(); - let status = runtime.state.status.clone(); - let phase = runtime.state.phase.clone(); let _ = app.emit( "game-creator-agent-runtime-update", - GameCreatorAgentRuntimeUpdateEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id, - run_id, - status, - phase, - runtime, - }, + game_creator_agent_runtime_update_event(root, runtime), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 9e62d76a0..60d8094a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -629,9 +629,30 @@ struct GameCreatorAgentRuntimeUpdateEvent { run_id: String, status: String, phase: String, + manifest_invalidated: bool, runtime: AgentRuntimeResult, } +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidatedEvent { + project_path: String, + agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorManifestInvalidationRelayEnvelope { + token: String, + event: GameCreatorManifestInvalidatedEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct GameCreatorManifestInvalidationEventSink { + port: u16, + token: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorAgentProgressEvent { @@ -2092,7 +2113,10 @@ fn main() { format!("启动 Agent Runner 失败:{error}"), ) })?; - attach_external_agent_runner_gui_owner() + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { if let Some(path) = setup_log.as_deref() { let details = @@ -2113,7 +2137,6 @@ fn main() { if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete"); } - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] if game_chat_launch.is_none() { open_developer_window(app.handle())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 69c7da1c9..03ac23701 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1,5 +1,8 @@ use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::{AgentRuntimeContextCompactionResult, GameCreatorMcpCatalog}; +use crate::{ + AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink, + GameCreatorMcpCatalog, +}; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::ffi::OsString; @@ -911,7 +914,9 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { shutdown_external_agent_runner_at(&config_dir) } -pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { +pub(crate) fn attach_external_agent_runner_gui_owner( + event_sink: &GameCreatorManifestInvalidationEventSink, +) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); let config_dir = external_agent_runner_config_dir() @@ -920,12 +925,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { let result = send_external_agent_runner_request( &endpoint, "runner.attach_gui_owner", - ExternalAgentRunnerRequestParams::default(), + ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink.port), + event_sink_token: Some(event_sink.token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, )?; - if result.get("attached").and_then(Value::as_bool) == Some(true) { + if result.get("attached").and_then(Value::as_bool) == Some(true) + && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) + { Ok(()) } else { - Err("Agent Runner attach_gui_owner 响应未确认 owner".to_string()) + Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string()) } } @@ -1181,6 +1192,8 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( run_id: run_id.map(str::to_string), action_id: action_id.map(str::to_string), steer_id: steer_id.map(str::to_string), + event_sink_port: None, + event_sink_token: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 5bc8667ef..aa17ffefe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,4 +1,5 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; +use crate::configure_game_creator_manifest_invalidation_event_sink; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; @@ -587,10 +588,27 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( "runner.attach_gui_owner" => { match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { Ok(true) => { + let event_sink = request + .params + .event_sink_port + .zip(request.params.event_sink_token.as_deref()) + .ok_or_else(|| { + "Agent Runner GUI owner 缺少 manifest 事件接收端".to_string() + }) + .and_then(|(port, token)| { + configure_game_creator_manifest_invalidation_event_sink(port, token) + }); + if let Err(error) = event_sink { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "event-sink-invalid", + redact_runner_secret(&error, &token), + ); + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, - json!({ "attached": true }), + json!({ "attached": true, "eventSinkAttached": true }), ) } Ok(false) => ExternalAgentRunnerResponse::failure( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 3eda73886..451309200 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 4; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -264,6 +264,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) action_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) steer_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) event_sink_token: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 98edcf5f0..4b92b1e10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -84,7 +84,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 4); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -583,7 +583,11 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { request_id: "gui-owner-attach-1".to_string(), token: token.to_string(), method: "runner.attach_gui_owner".to_string(), - params: ExternalAgentRunnerRequestParams::default(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_318), + event_sink_token: Some("b".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, }, &state, ); @@ -599,6 +603,7 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { assert!(state.draining.load(Ordering::Acquire)); assert!(state.force_shutdown_requested.load(Ordering::Acquire)); assert!(state.shutdown_requested.load(Ordering::Acquire)); + crate::clear_game_creator_manifest_invalidation_event_sink_for_test(); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index aaa35066c..726e055e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -13,6 +13,49 @@ static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000); static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); +#[test] +fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试") + .expect("init runtime event contract project"); + let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan") + .expect("read non-Supervisor runtime"); + let event = game_creator_agent_runtime_update_event(&root, runtime); + let serialized = serde_json::to_value(event).expect("serialize runtime update event"); + + assert_eq!(serialized["agentId"], "art-asset-plan"); + assert_eq!(serialized["manifestInvalidated"], true); + + let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation relay fixture"); + let relay_port = relay_listener + .local_addr() + .expect("read manifest invalidation relay fixture address") + .port(); + let relay_token = "a".repeat(64); + configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token) + .expect("configure manifest invalidation relay fixture"); + emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); + let (mut relay_stream, _) = relay_listener + .accept() + .expect("accept manifest invalidation relay"); + relay_stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("set manifest invalidation relay read timeout"); + let mut relay_payload = Vec::new(); + relay_stream + .read_to_end(&mut relay_payload) + .expect("read manifest invalidation relay"); + clear_game_creator_manifest_invalidation_event_sink_for_test(); + let relay: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay"); + assert_eq!(relay.token, relay_token); + assert_eq!(relay.event.project_path, root.to_string_lossy()); + assert_eq!(relay.event.agent_id, "art-asset-plan"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { assert!(game_creator_gui_run_event_requests_runner_shutdown( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 006cdef0d..121b3ec2d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -53,6 +53,7 @@ import type { GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorLlmConfigStatus, + GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, GenerateLocalGameDraftResult, ImportCanvasExportResult, @@ -612,6 +613,16 @@ export function App({ ); const localProjectPathRef = useRef(null); localProjectPathRef.current = localProject?.projectPath ?? null; + const manifestRefreshMountedRef = useRef(true); + const manifestRefreshStatesRef = useRef( + new Map< + string, + { + pending: boolean; + inFlight: Promise | null; + } + >(), + ); const [manifest, setManifest] = useState( initialProjectManifest ?? seedManifest, ); @@ -818,6 +829,77 @@ export function App({ const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; + + const refreshManifest = useCallback( + ( + nextProjectPath = localProjectPathRef.current ?? '', + ): Promise => { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + return Promise.resolve(); + } + + const refreshStates = manifestRefreshStatesRef.current; + let refreshState = refreshStates.get(nextProjectPath); + if (!refreshState) { + refreshState = { pending: false, inFlight: null }; + refreshStates.set(nextProjectPath, refreshState); + } + refreshState.pending = true; + if (refreshState.inFlight) { + return refreshState.inFlight; + } + + const activeRefreshState = refreshState; + const refreshPromise = (async () => { + try { + while (activeRefreshState.pending) { + activeRefreshState.pending = false; + const projectScopeVersion = projectScopeVersionRef.current; + try { + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + manifestRefreshMountedRef.current && + localProjectPathRef.current === nextProjectPath && + projectScopeVersionRef.current === projectScopeVersion + ) { + setManifest(nextManifest); + } + } catch { + // Dev-only convenience; command errors are surfaced by the action that triggered them. + } + if ( + !manifestRefreshMountedRef.current || + localProjectPathRef.current !== nextProjectPath || + projectScopeVersionRef.current !== projectScopeVersion + ) { + activeRefreshState.pending = false; + } + } + } finally { + activeRefreshState.inFlight = null; + if (!activeRefreshState.pending) { + refreshStates.delete(nextProjectPath); + } + } + })(); + activeRefreshState.inFlight = refreshPromise; + return refreshPromise; + }, + [], + ); + + useEffect(() => { + const refreshStates = manifestRefreshStatesRef.current; + manifestRefreshMountedRef.current = true; + return () => { + manifestRefreshMountedRef.current = false; + refreshStates.clear(); + }; + }, []); const projectSupervisorRuntimeSyncingRef = useRef(new Set()); const projectSupervisorRefreshConversationRef = useRef< | (( @@ -1306,6 +1388,9 @@ export function App({ if (payload.projectPath !== localProjectPathRef.current) { return; } + if (payload.manifestInvalidated) { + void refreshManifest(payload.projectPath); + } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { appendGameChatFinalReplyMessages(payload.projectPath, [ @@ -1395,10 +1480,43 @@ export function App({ }, [ appendGameChatFinalReplyMessages, gameChatOnly, + refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-manifest-invalidated', + (event) => { + if (event.payload.projectPath !== localProjectPathRef.current) { + return; + } + void refreshManifest(event.payload.projectPath); + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // In-process Runtime events continue to carry the same invalidation signal. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [refreshManifest]); + useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; @@ -9982,25 +10100,6 @@ export function App({ } } - async function refreshManifest( - nextProjectPath = resolveChatProjectPath(localProject) ?? '', - ) { - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath) { - return; - } - - try { - const nextManifest = await invoke( - 'get_local_game_manifest', - { projectPath: nextProjectPath }, - ); - setManifest(nextManifest); - } catch { - // Dev-only convenience; command errors are surfaced by the action that triggered them. - } - } - async function loadAgentRunTraceFile( relativePath: string, nextProjectPath = resolveChatProjectPath(localProject) ?? '', diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 8cec58340..3c44f0284 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -426,9 +426,15 @@ export interface GameCreatorAgentRuntimeUpdateEvent { runId: string; status: string; phase: string; + manifestInvalidated: boolean; runtime: AgentRuntimeResult; } +export interface GameCreatorManifestInvalidatedEvent { + projectPath: string; + agentId: string; +} + export const gameCreatorLlmReasoningEfforts = [ 'default', 'low', diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8e186143d..134591abf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -440,8 +440,12 @@ export default function ProjectDevelopmentView({ }); const [mediaDuration, setMediaDuration] = useState(null); const resourceCanvasRef = useRef(null); + const resourceSearchRef = useRef(null); const resourceFocusRef = useRef(null); const resourceFocusTriggerIdRef = useRef(null); + const previousFocusedResourceIdRef = useRef(null); + const resourceFocusProjectPathRef = useRef(projectPath); + const suppressResourceFocusRestoreRef = useRef(false); const resourceListScrollRef = useRef({ left: 0, top: 0 }); const restoreResourceListScrollRef = useRef(false); const dependencyDescriptionId = useId(); @@ -731,6 +735,10 @@ export default function ProjectDevelopmentView({ resources.find((resource) => resource.id === selectedResourceId) ?? null; const focusedResource = resources.find((resource) => resource.id === focusedResourceId) ?? null; + const focusedResourcePath = focusedResource?.path ?? null; + const focusedResourceCategory = focusedResource?.category ?? null; + const focusedResourceContent = focusedResource?.content; + const focusedResourceMediaType = focusedResource?.mediaType ?? null; const focusedResourceIsImage = Boolean( focusedResource && isRasterImageResource(focusedResource), ); @@ -793,12 +801,22 @@ export default function ProjectDevelopmentView({ useEffect(() => { if (embeddedPreviewUrl) { + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; setFocusedResourceId(null); setMode('run'); } }, [embeddedPreviewUrl]); - useEffect(() => { + useLayoutEffect(() => { + if (resourceFocusProjectPathRef.current === projectPath) { + return; + } + resourceFocusProjectPathRef.current = projectPath; + suppressResourceFocusRestoreRef.current = true; + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; setSelectedResourceId(null); setFocusedResourceId(null); resourceListScrollRef.current = { left: 0, top: 0 }; @@ -820,7 +838,11 @@ export default function ProjectDevelopmentView({ }, [focusedResourceId]); useEffect(() => { - if (!focusedResource || !focusedResourceIsImage) { + if ( + !focusedResourceId || + !focusedResourcePath || + !focusedResourceIsImage + ) { setImagePreview({ status: 'idle', resourceId: null }); return undefined; } @@ -828,23 +850,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setImagePreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '图片预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setImagePreview({ status: 'loading', resourceId: focusedResource.id }); + setImagePreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_image_preview', { projectPath, - relativePath: focusedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setImagePreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -853,7 +879,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setImagePreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: imagePreviewErrorMessage(error), }); } @@ -861,22 +887,31 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [focusedResource, focusedResourceIsImage, projectPath]); + }, [ + focusedResourceId, + focusedResourceIsImage, + focusedResourcePath, + projectPath, + ]); useEffect(() => { - if (!focusedResource || focusedResource.category !== 'document') { + if ( + !focusedResourceId || + !focusedResourcePath || + focusedResourceCategory !== 'document' + ) { setTextPreview({ status: 'idle', resourceId: null }); return undefined; } - if (focusedResource.content !== undefined) { + if (focusedResourceContent !== undefined) { setTextPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview: { - path: focusedResource.path, - mediaType: focusedResource.mediaType, - byteLen: new TextEncoder().encode(focusedResource.content).byteLength, - content: focusedResource.content, + path: focusedResourcePath, + mediaType: focusedResourceMediaType ?? 'text/plain', + byteLen: new TextEncoder().encode(focusedResourceContent).byteLength, + content: focusedResourceContent, }, }); return undefined; @@ -885,23 +920,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setTextPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '文档预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setTextPreview({ status: 'loading', resourceId: focusedResource.id }); + setTextPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_text_preview', { projectPath, - relativePath: focusedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setTextPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -910,7 +949,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setTextPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: mediaPreviewErrorMessage(error), }); } @@ -918,11 +957,20 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [focusedResource, projectPath]); + }, [ + focusedResourceCategory, + focusedResourceContent, + focusedResourceId, + focusedResourceMediaType, + focusedResourcePath, + projectPath, + ]); useEffect(() => { if ( - !focusedResource || + !focusedResourceId || + !focusedResourcePath || + !focusedResourceCategory || (!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio) ) { setMediaPreview({ status: 'idle', resourceId: null }); @@ -933,7 +981,7 @@ export default function ProjectDevelopmentView({ if (!invoke) { setMediaPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: '媒体预览需要在客户端内打开', }); return undefined; @@ -941,17 +989,21 @@ export default function ProjectDevelopmentView({ let cancelled = false; setMediaDuration(null); - setMediaPreview({ status: 'loading', resourceId: focusedResource.id }); + setMediaPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_media_preview', { projectPath, - relativePath: focusedResource.path, - category: focusedResource.category, + relativePath: focusedResourcePath, + category: focusedResourceCategory, }) .then((preview) => { if (!cancelled) { setMediaPreview({ status: 'loaded', - resourceId: focusedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -960,7 +1012,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setMediaPreview({ status: 'failed', - resourceId: focusedResource.id, + resourceId: focusedResourceId, error: mediaPreviewErrorMessage(error), }); } @@ -969,17 +1021,39 @@ export default function ProjectDevelopmentView({ cancelled = true; }; }, [ - focusedResource, + focusedResourceCategory, + focusedResourceId, focusedResourceIsAudio, focusedResourceIsExtendedArtMedia, + focusedResourcePath, projectPath, ]); useLayoutEffect(() => { - if (focusedResource) { - resourceFocusRef.current?.focus({ preventScroll: true }); + if (suppressResourceFocusRestoreRef.current) { + suppressResourceFocusRestoreRef.current = false; + previousFocusedResourceIdRef.current = focusedResourceId; return; } + if (focusedResourceId && !focusedResource) { + previousFocusedResourceIdRef.current = null; + resourceFocusTriggerIdRef.current = null; + restoreResourceListScrollRef.current = false; + setSelectedResourceId((current) => + current === focusedResourceId ? null : current, + ); + setFocusedResourceId(null); + resourceSearchRef.current?.focus({ preventScroll: true }); + return; + } + if (focusedResourceId && focusedResource) { + if (previousFocusedResourceIdRef.current !== focusedResourceId) { + resourceFocusRef.current?.focus({ preventScroll: true }); + } + previousFocusedResourceIdRef.current = focusedResourceId; + return; + } + previousFocusedResourceIdRef.current = null; if (!restoreResourceListScrollRef.current) { return; } @@ -988,16 +1062,22 @@ export default function ProjectDevelopmentView({ canvas.scrollLeft = resourceListScrollRef.current.left; canvas.scrollTop = resourceListScrollRef.current.top; const triggerResourceId = resourceFocusTriggerIdRef.current; - if (triggerResourceId) { - Array.from( - canvas.querySelectorAll('[data-resource-id]'), - ) - .find((card) => card.dataset.resourceId === triggerResourceId) - ?.focus({ preventScroll: true }); + const triggerCard = triggerResourceId + ? Array.from( + canvas.querySelectorAll('[data-resource-id]'), + ).find((card) => card.dataset.resourceId === triggerResourceId) + : null; + if (triggerCard) { + triggerCard.focus({ preventScroll: true }); + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); } + } else { + resourceSearchRef.current?.focus({ preventScroll: true }); } + resourceFocusTriggerIdRef.current = null; restoreResourceListScrollRef.current = false; - }, [focusedResource]); + }, [focusedResource, focusedResourceId]); const handleResourceSelect = useCallback((resourceId: string) => { const canvas = resourceCanvasRef.current; @@ -1007,6 +1087,7 @@ export default function ProjectDevelopmentView({ top: canvas.scrollTop, }; } + suppressResourceFocusRestoreRef.current = false; resourceFocusTriggerIdRef.current = resourceId; setSelectedResourceId(resourceId); setFocusedResourceId(resourceId); @@ -1021,6 +1102,9 @@ export default function ProjectDevelopmentView({ if (!runAvailable) { return; } + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; setFocusedResourceId(null); setMode('run'); } @@ -1177,6 +1261,7 @@ export default function ProjectDevelopmentView({