diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 4bdbb49cd..e19b10e7e 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -161,9 +161,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Check server-rs boundaries - run: npm run check:server-rs-ddd - - name: Prepare server-rs Rust dependencies shell: bash run: | @@ -181,6 +178,9 @@ jobs: sleep $((attempt * 2)) done + - name: Check server-rs boundaries + run: npm run check:server-rs-ddd + - name: Run server-rs workspace tests run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index ad77d7f19..6b03ae39c 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -885,6 +885,10 @@ function readBrowserDom(url) { function resolveChromeBin() { for (const candidate of [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 4cc52b685..5c9613bdd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -5708,7 +5708,7 @@ mod canvas_generation_tests { fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream - .set_read_timeout(Some(Duration::from_secs(2))) + .set_read_timeout(Some(Duration::from_secs(10))) .expect("set request read timeout"); let mut bytes = Vec::new(); let mut buffer = [0_u8; 4096]; @@ -6702,7 +6702,7 @@ mod canvas_generation_tests { #[tokio::test] async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { - let temporary = tempfile::tempdir().expect("create accepted scan project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") .expect("init accepted scan project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 7b13a6176..0de156f4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -838,7 +838,7 @@ mod external_generation_state_tests { #[test] fn prepared_generation_state_reuses_identity_and_transitions_to_accepted() { - let temporary = tempfile::tempdir().expect("create generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "generation-ledger", "生成账本测试") .expect("init project"); @@ -955,7 +955,7 @@ mod external_generation_state_tests { #[test] fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { - let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-"); let root = temporary.path(); init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") .expect("init project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fa5493585..6de6a816e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -790,7 +790,7 @@ mod tests { #[test] fn generation_cleanup_failure_preserves_pending_identity_anchor() { - let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-"); let root = temporary.path(); let run_id = "generation-cleanup-order-run"; init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3b711d32c..756cda481 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -463,7 +463,8 @@ mod tests { root_source: &str, suffix: &str, ) -> String { - let temporary = tempfile::tempdir().expect("temporary project root"); + let temporary = + crate::tests::canonical_test_tempdir(&format!("provider-role-overlay-{suffix}-")); let root = temporary.path().join("project"); init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test") .expect("project init"); @@ -549,7 +550,7 @@ mod tests { const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载"; const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker"; - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("provider-request-project-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试") .expect("project init"); @@ -806,7 +807,7 @@ mod tests { #[test] fn planning_request_advertises_only_native_mcp_functions() { - let directory = tempfile::tempdir().expect("temp project directory"); + let directory = crate::tests::canonical_test_tempdir("native-mcp-prompt-"); let root = directory.path().join("project"); init_local_game_project_at(&root, "project-mcp", "MCP 原生函数说明测试") .expect("project init"); 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..b35413714 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,12 @@ 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(); +#[cfg(test)] +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = + std::sync::Mutex::new(()); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -234,15 +240,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::acquire_game_creator_manifest_invalidation_event_sink_test_guard; #[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..195bcd2eb 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,174 @@ 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) struct GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl GameCreatorManifestInvalidationEventSinkTestGuard { + pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> { + configure_game_creator_manifest_invalidation_event_sink(port, token) + } + + pub(crate) fn configured_sink(&self) -> Option { + lock_game_creator_manifest_invalidation_event_sink().clone() + } +} + +#[cfg(test)] +impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { + fn drop(&mut self) { + *lock_game_creator_manifest_invalidation_event_sink() = None; + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( +) -> GameCreatorManifestInvalidationEventSinkTestGuard { + let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: isolation, + } +} + +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/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 843dc4c06..84866b305 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -63,15 +63,9 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() } #[tokio::test] -async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-reconciliation-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); +async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() { + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); bind_game_creator_agent_runtime_run_profile_at( @@ -234,51 +228,13 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation")); assert!(!agent_db.contains("test-operation-id")); - let resumed = resume_game_creator_agent_background_tasks_at(&root) - .expect("scan durable runtime state after simulated runner restart"); - assert!(resumed.iter().any(|result| { - result.state.agent_id == runtime.agent_id - && result.state.run_id == runtime.run_id - && result.state.phase == "needs-reconciliation" - })); - let recovered_pending = read_game_creator_agent_runtime_pending_tool_action( - &root, - &runtime.agent_id, - &runtime.run_id, - ) - .expect("read pending action after recovery scan"); - assert_eq!(recovered_pending, durable_pending); - let recovered_batch = read_game_creator_agent_runtime_provider_action_batch( - &root, - &runtime.agent_id, - &runtime.run_id, - ) - .expect("read provider action batch after recovery scan"); - assert_eq!(recovered_batch, preserved_batch); - assert!(game_creator_agent_runtime_external_generation_exists( - &root, - &runtime.agent_id, - &runtime.run_id - )); - assert_eq!( - fs::read_to_string(root.join(".agent/agent.db")).expect("agent db after recovery scan"), - agent_db, - "needs-reconciliation recovery barrier must not append a replay receipt" - ); - fs::remove_dir_all(root).ok(); } #[test] fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { - let root = std::env::temp_dir().join(format!( - "genarrative-game-chat-deadline-local-cleanup-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos() - )); + let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-"); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 5f1996351..67c7400c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -1207,7 +1207,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() { fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { const RUN_ID: &str = "autonomous-manifest-waiting-parent"; const TASK: &str = "生成完整小游戏并完成项目任务图"; - let temporary = tempfile::tempdir().expect("create manifest waiting root"); + let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-waiting-project", TASK) .expect("init manifest waiting project"); @@ -1421,7 +1421,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏"; const TEST_KEY: &str = "autonomous-final-reply-fallback-key"; - let temporary = tempfile::tempdir().expect("create autonomous fallback root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-fallback-project", TASK) .expect("init autonomous fallback project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index e7370c78f..0aa6585e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -13,7 +13,6 @@ where .await .expect("pending continuation task must exist") } - async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( ); emit_game_creator_agent_runtime_update(root, &runtime.agent_id); } - pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 246cc05a1..1bc5adaa9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -1332,7 +1332,7 @@ mod pending_recovery_tests { #[test] fn observed_unknown_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create prepared pending project"); + let temporary = crate::tests::canonical_test_tempdir("prepared-pending-"); let root = temporary.path(); init_local_game_project_at(root, "prepared-pending", "原俄罗斯方块项目") .expect("init prepared pending project"); @@ -1413,7 +1413,7 @@ mod pending_recovery_tests { #[test] fn legacy_executing_canvas_generation_returns_to_same_approved_action() { - let temporary = tempfile::tempdir().expect("create executing prepared project"); + let temporary = crate::tests::canonical_test_tempdir("executing-prepared-"); let root = temporary.path(); init_local_game_project_at(root, "executing-prepared", "旧版俄罗斯方块项目") .expect("init executing prepared project"); @@ -1490,7 +1490,7 @@ mod pending_recovery_tests { #[test] fn observed_postprocessing_failure_resumes_from_accepted_generation() { - let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let temporary = crate::tests::canonical_test_tempdir("accepted-recovery-"); let root = temporary.path(); init_local_game_project_at(root, "accepted-recovery", "俄罗斯方块素材后处理恢复") .expect("init accepted recovery project"); @@ -1560,7 +1560,7 @@ mod pending_recovery_tests { #[test] fn canvas_reconciliation_keeps_the_context_plan_step_active() { - let temporary = tempfile::tempdir().expect("create reconciliation context project"); + let temporary = crate::tests::canonical_test_tempdir("reconciliation-context-"); let root = temporary.path(); init_local_game_project_at(root, "reconciliation-context", "俄罗斯方块恢复上下文") .expect("init reconciliation context project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 974216689..364eca9cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests { #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { - let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); let root = temporary.path(); let run_id = "orphan-generation-recovery-run"; init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 2cd3b3af9..700ad713a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -22,7 +22,7 @@ fn autonomous_fixture_with_source( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); let session_id = resolve_agent_conversation_session_id_at( @@ -223,7 +223,7 @@ fn autonomous_fixture_with_setup( AgentRuntimeState, AgentRuntimeAutonomousCompletionContract, ) { - let temporary = tempfile::tempdir().expect("create autonomous fixture root"); + let temporary = crate::tests::canonical_test_tempdir("autonomous-setup-fixture-"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "autonomous-project", task).expect("init project"); setup(&root); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index b9ed6e360..9f2187da8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -826,10 +826,11 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system clock should be after epoch") .as_nanos(); - let root = std::env::temp_dir().join(format!( - "genarrative-context-window-boundary-{}-{unique}", + let temporary = crate::tests::canonical_test_tempdir(&format!( + "context-window-boundary-{}-{unique}-", std::process::id() )); + let root = temporary.path().join("project"); init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5c9f77367..bff274d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode) } +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + #[tauri::command] pub(crate) fn update_local_project_resource_canvas_layout( project_path: String, @@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview( load_local_project_image_preview(root, &normalized_path) } +#[tauri::command] +pub(crate) fn read_local_project_text_preview( + project_path: String, + relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let is_registered_document = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && is_supported_project_text_resource(&asset.local_path, &asset.media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_text_resource(&normalized_path, "") + }); + if !is_registered_document { + return Err("只能读取当前项目已登记的文档资源".to_string()); + } + load_local_project_text_preview(root, &normalized_path) +} + +#[tauri::command] +pub(crate) fn read_local_project_media_preview( + project_path: String, + relative_path: String, + category: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let kind = match category.trim() { + "art" => ProjectMediaPreviewKind::Art, + "audio" => ProjectMediaPreviewKind::Audio, + _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + }; + let is_registered_media = manifest.assets.iter().any(|asset| { + asset.local_path == normalized_path + && match kind { + ProjectMediaPreviewKind::Art => { + is_supported_project_art_media_resource(&asset.local_path, &asset.media_type) + } + ProjectMediaPreviewKind::Audio => { + is_supported_project_audio_resource(&asset.local_path, &asset.media_type) + } + } + }) || (kind == ProjectMediaPreviewKind::Art + && manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_art_media_resource(&normalized_path, "") + })); + if !is_registered_media { + return Err("只能预览当前项目已登记的媒体资源".to_string()); + } + load_local_project_media_preview(root, &normalized_path, kind) +} + #[tauri::command] pub(crate) fn write_local_project_file( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 6ab5a5979..5d6362ba5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path( Ok(()) } -fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> { +pub(crate) fn validate_agent_runtime_inspection_ancestors( + root: &Path, + path: &Path, +) -> Result<(), String> { let relative = path .strip_prefix(root) .map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?; @@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { use std::os::unix::fs::MetadataExt; left.dev() == right.dev() && left.ino() == right.ino() @@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { } #[cfg(not(unix))] -fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { +pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { left.len() == right.len() && left.modified().ok() == right.modified().ok() } #[cfg(unix)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, @@ -479,7 +482,7 @@ fn same_open_file_identity( } #[cfg(windows)] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( left_file: &fs::File, _left: &fs::Metadata, right_file: &fs::File, @@ -489,7 +492,7 @@ fn same_open_file_identity( } #[cfg(not(any(unix, windows)))] -fn same_open_file_identity( +pub(crate) fn same_open_file_identity( _left_file: &fs::File, left: &fs::Metadata, _right_file: &fs::File, 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 d0f317a6b..60d8094a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -23,8 +23,9 @@ use reqwest::header; use serde::{Deserialize, Serialize}; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, - GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, - GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, + validate_game_iteration_versions, GameCreationAgentArtifactTrace, + GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, + GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, @@ -72,6 +73,7 @@ mod project; mod provider_handoff; mod provider_retry; mod repository_context; +mod resource_inspect; mod runner; mod swarm_cli; mod tool_plan_handoff; @@ -101,6 +103,7 @@ use preview::*; use process_session::*; use project::*; use repository_context::*; +use resource_inspect::*; use runner::*; use swarm_cli::*; use user_input::*; @@ -626,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 { @@ -1848,6 +1872,7 @@ mod game_chat_release_client_exit_tests { } } +#[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2088,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 = @@ -2109,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())?; @@ -2170,6 +2197,8 @@ fn main() { list_local_project_files, read_local_project_file, read_local_project_image_preview, + read_local_project_text_preview, + read_local_project_media_preview, write_local_project_file, delete_local_project_file, read_local_game_memory, @@ -2201,6 +2230,7 @@ fn main() { stop_local_game_preview_if_matches, get_local_game_preview_status, read_local_project_resource_canvas_layout, + read_local_project_resource_graph, update_local_project_resource_canvas_layout, get_local_game_project_revision, get_local_game_manifest diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index b4d79f7f9..28684775e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -1980,7 +1980,10 @@ mod tests { } fn mcp_test_project(label: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize MCP test temp root"); + let root = temp_root.join(format!( "game-creator-mcp-{label}-{}-{}", std::process::id(), MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index aee2ea42a..1099d78e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -10,6 +10,7 @@ mod export; mod filesystem; mod manifest; mod memory; +mod resource_dependency_graph; mod resource_layout; mod verification; @@ -20,5 +21,6 @@ pub(crate) use export::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; +pub(crate) use resource_dependency_graph::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 91fb6a9ff..80216aa36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1,5 +1,169 @@ use super::*; +const MANIFEST_LOCK_WAIT_ATTEMPTS: usize = 500; +const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; + +static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct ManifestWriteLock { + _file: File, +} + +fn manifest_lock_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.lock", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json") + )) +} + +fn acquire_manifest_write_lock(path: &Path) -> Result { + for attempt in 0..MANIFEST_LOCK_WAIT_ATTEMPTS { + if let Some(file) = try_open_manifest_write_lock_file(path)? { + return Ok(ManifestWriteLock { _file: file }); + } + if attempt + 1 < MANIFEST_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(MANIFEST_LOCK_WAIT_MILLIS)); + } + } + Err("manifest 正在被其他进程写入,请稍后重试".to_string()) +} + +#[cfg(unix)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + let file = options + .open(&lock_path) + .map_err(|error| format!("安全打开 manifest 锁失败:{}: {error}", lock_path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取 manifest 锁句柄元数据失败:{}: {error}", + lock_path.display() + ) + })?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "manifest 锁必须是当前用户持有的无硬链接普通文件:{}", + lock_path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?; + let path_metadata = fs::symlink_metadata(&lock_path) + .map_err(|error| format!("复核 manifest 锁路径失败:{}: {error}", lock_path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "manifest 锁路径在安全打开期间发生替换:{}", + lock_path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核 manifest 锁句柄失败:{}: {error}", lock_path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "manifest 锁必须由当前用户持有且权限为 0600:{}", + lock_path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let _open_guard = MANIFEST_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; + let lock_path = manifest_lock_path(path); + if let Ok(metadata) = fs::symlink_metadata(&lock_path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "manifest 锁必须是普通文件且不能是 reparse point:{}", + lock_path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&lock_path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取 manifest 系统文件锁失败:{}: {error}", + lock_path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_manifest_write_lock_file(path: &Path) -> Result, String> { + Err(format!( + "当前平台不支持 manifest 系统文件锁:{}", + manifest_lock_path(path).display() + )) +} + pub(crate) fn init_local_game_project_at( root: &Path, project_id: &str, @@ -631,8 +795,11 @@ pub(crate) fn read_manifest(path: &Path) -> Result( @@ -709,12 +876,39 @@ pub(crate) fn write_manifest( path: &Path, manifest: &GameCreationAppManifest, ) -> Result<(), String> { + write_manifest_with_lock_hook(path, manifest, || {}) +} + +fn write_manifest_with_lock_hook( + path: &Path, + manifest: &GameCreationAppManifest, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(), +{ + validate_game_iteration_versions(&manifest.versions) + .map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?; let payload = serde_json::to_string_pretty(manifest) .map_err(|error| format!("序列化 manifest 失败:{error}"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } + let _write_lock = acquire_manifest_write_lock(path)?; + after_lock(); + if manifest_storage_exists(path)? { + let existing = read_manifest(path)?; + if existing.versions.len() > manifest.versions.len() + || existing + .versions + .iter() + .zip(&manifest.versions) + .any(|(existing, candidate)| existing != candidate) + { + return Err("项目版本记录写入后不可修改、删除或重排".to_string()); + } + } match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("manifest 必须是普通文件".to_string()); @@ -745,7 +939,12 @@ pub(crate) fn write_manifest( temp_path.display() ) })?; - install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to)) + install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + let installed = read_manifest(path)?; + if installed != *manifest { + return Err("manifest 安装后回读与待写入内容不一致".to_string()); + } + Ok(()) } pub(crate) fn sanitize_file_name(file_name: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index c82b0144d..cf4c331fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -1,4 +1,7 @@ use super::*; +use shared_contracts::game_creation_app::{ + GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding, +}; fn unique_manifest_test_root(test_name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -40,6 +43,126 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +fn version_fixture( + version_id: &str, + parent_version_id: Option<&str>, + project_revision: u64, + created_reason: GameIterationVersionCreatedReason, +) -> GameIterationVersion { + GameIterationVersion { + version_id: version_id.to_string(), + parent_version_id: parent_version_id.map(str::to_string), + project_revision, + resource_bindings: vec![GameIterationVersionResourceBinding { + slot_id: "player".to_string(), + resource_id: "asset-player".to_string(), + }], + created_reason, + created_at: project_revision, + } +} + +#[test] +fn manifest_versions_are_append_only_at_the_storage_boundary() { + let root = unique_manifest_test_root("versions-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目"); + manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &manifest).expect("write initial version"); + + manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + write_manifest(&manifest_path, &manifest).expect("append child version"); + + let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes"); + manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string(); + let error = + write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version"); + assert!(error.contains("不可修改、删除或重排"), "{error}"); + assert_eq!( + fs::read(&manifest_path).expect("read untouched manifest bytes"), + stable_payload + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_snapshot() { + let root = unique_manifest_test_root("versions-concurrent-append-only"); + let manifest_path = root.join(".agent/manifest.json"); + let mut stale_manifest = new_game_creation_app_manifest("project-versioned", "并发版本项目"); + stale_manifest.versions.push(version_fixture( + "version-root", + None, + 1, + GameIterationVersionCreatedReason::Initial, + )); + write_manifest(&manifest_path, &stale_manifest).expect("write initial version"); + + let mut newer_manifest = stale_manifest.clone(); + newer_manifest.versions.push(version_fixture( + "version-child", + Some("version-root"), + 2, + GameIterationVersionCreatedReason::AgentRevision, + )); + let (newer_locked_tx, newer_locked_rx) = mpsc::channel(); + let (release_newer_tx, release_newer_rx) = mpsc::channel(); + let newer_path = manifest_path.clone(); + let newer_writer = std::thread::spawn(move || { + write_manifest_with_lock_hook(&newer_path, &newer_manifest, || { + newer_locked_tx + .send(()) + .expect("signal newer lock acquired"); + release_newer_rx.recv().expect("release newer writer"); + }) + }); + newer_locked_rx + .recv_timeout(Duration::from_secs(2)) + .expect("newer writer acquires manifest lock"); + + let (stale_started_tx, stale_started_rx) = mpsc::channel(); + let stale_path = manifest_path.clone(); + let stale_writer = std::thread::spawn(move || { + stale_started_tx + .send(()) + .expect("signal stale writer started"); + write_manifest(&stale_path, &stale_manifest) + }); + stale_started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("stale writer starts while newer writer holds lock"); + release_newer_tx.send(()).expect("release newer writer"); + + newer_writer + .join() + .expect("join newer writer") + .expect("install newer manifest"); + let stale_error = stale_writer + .join() + .expect("join stale writer") + .expect_err("reject stale manifest after newer version is installed"); + assert!( + stale_error.contains("不可修改、删除或重排"), + "{stale_error}" + ); + let installed = read_manifest(&manifest_path).expect("read final manifest"); + assert_eq!(installed.versions.len(), 2); + assert_eq!(installed.versions[1].version_id, "version-child"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn manifest_install_uses_previous_when_direct_replace_fails() { let root = unique_manifest_test_root("replace-fallback"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs new file mode 100644 index 000000000..5a4a36fb1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -0,0 +1,1023 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const RESOURCE_GRAPH_AGENT_DB_READ_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphNodeInput { + pub resource_id: String, + #[serde(default)] + pub manifest_asset_id: Option, + #[serde(default)] + pub producer_task_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceReferenceEdge { + pub id: String, + pub kind: String, + pub source_resource_id: String, + pub target_resource_id: String, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceTaskFlow { + pub id: String, + pub kind: String, + pub source_task_id: String, + pub target_task_id: String, + pub source_resource_ids: Vec, + pub target_resource_ids: Vec, + pub cyclic: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceConnectionIndex { + pub resource_id: String, + pub upstream_reference_resource_ids: Vec, + pub downstream_reference_resource_ids: Vec, + pub reference_edge_ids: Vec, + pub task_flow_ids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceProducerAssignment { + pub resource_id: String, + pub task_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceDependencyDepth { + pub resource_id: String, + pub dependency_depth: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectResourceGraphReadModel { + pub resource_ids: Vec, + pub reference_edges: Vec, + pub task_flows: Vec, + pub connection_index: Vec, + pub producer_assignments: Vec, + pub dependency_depths: Vec, + pub unresolved_reference_resource_ids: Vec, + pub cyclic_resource_ids: Vec, + pub cyclic_task_ids: Vec, + pub producer_mapping_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectedEdge { + id: String, + source_id: String, + target_id: String, +} + +#[derive(Debug, Default)] +struct CycleAnalysis { + cyclic_node_ids: BTreeSet, + cyclic_edge_ids: BTreeSet, + component_by_node: BTreeMap, +} + +#[derive(Debug, Default)] +struct MutableConnectionIndex { + upstream_reference_resource_ids: BTreeSet, + downstream_reference_resource_ids: BTreeSet, + reference_edge_ids: BTreeSet, + task_flow_ids: BTreeSet, +} + +fn stable_edge_id(kind: &str, source_id: &str, target_id: &str) -> String { + let pair = serde_json::to_string(&(source_id, target_id)) + .expect("serializing two resource graph identifiers cannot fail"); + format!("{kind}:{pair}") +} + +fn analyze_directed_cycles<'a>( + node_ids: impl IntoIterator, + edges: &[DirectedEdge], +) -> CycleAnalysis { + let mut nodes = node_ids.into_iter().cloned().collect::>(); + for edge in edges { + nodes.insert(edge.source_id.clone()); + nodes.insert(edge.target_id.clone()); + } + + let mut adjacency = nodes + .iter() + .map(|node_id| (node_id.clone(), Vec::::new())) + .collect::>(); + let mut reverse_adjacency = adjacency.clone(); + for edge in edges { + adjacency + .entry(edge.source_id.clone()) + .or_default() + .push(edge.target_id.clone()); + reverse_adjacency + .entry(edge.target_id.clone()) + .or_default() + .push(edge.source_id.clone()); + } + + let mut visited = BTreeSet::new(); + let mut finish_order = Vec::with_capacity(nodes.len()); + for root in &nodes { + if !visited.insert(root.clone()) { + continue; + } + let mut stack = vec![(root.clone(), 0usize)]; + while let Some((node_id, next_index)) = stack.last_mut() { + let neighbors = adjacency.get(node_id).map(Vec::as_slice).unwrap_or(&[]); + if let Some(next) = neighbors.get(*next_index) { + *next_index += 1; + if visited.insert(next.clone()) { + stack.push((next.clone(), 0)); + } + } else { + let completed = node_id.clone(); + stack.pop(); + finish_order.push(completed); + } + } + } + + let mut component_by_node = BTreeMap::::new(); + let mut component_sizes = Vec::::new(); + for root in finish_order.into_iter().rev() { + if component_by_node.contains_key(&root) { + continue; + } + let component_id = component_sizes.len(); + let mut size = 0usize; + let mut stack = vec![root.clone()]; + component_by_node.insert(root, component_id); + while let Some(node_id) = stack.pop() { + size += 1; + for neighbor in reverse_adjacency + .get(&node_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + { + if !component_by_node.contains_key(neighbor) { + component_by_node.insert(neighbor.clone(), component_id); + stack.push(neighbor.clone()); + } + } + } + component_sizes.push(size); + } + + let mut result = CycleAnalysis::default(); + for edge in edges { + let source_component = component_by_node.get(&edge.source_id); + let target_component = component_by_node.get(&edge.target_id); + if source_component.is_some() + && source_component == target_component + && (component_sizes + .get(source_component.copied().unwrap_or_default()) + .copied() + .unwrap_or_default() + > 1 + || edge.source_id == edge.target_id) + { + result.cyclic_node_ids.insert(edge.source_id.clone()); + result.cyclic_node_ids.insert(edge.target_id.clone()); + result.cyclic_edge_ids.insert(edge.id.clone()); + } + } + result.component_by_node = component_by_node; + result +} + +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], + minimum_depth_by_node: &BTreeMap, +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + for (node_id, minimum_depth) in minimum_depth_by_node { + let Some(component) = analysis.component_by_node.get(node_id) else { + continue; + }; + depth_by_component[*component] = depth_by_component[*component].max(*minimum_depth); + } + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + +fn audit_asset_producers( + records: &[serde_json::Value], + task_ids: &BTreeSet, +) -> BTreeMap { + let mut candidates = BTreeMap::>::new(); + for record in records { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.canvas.asset_generate") + { + continue; + } + let Some(asset_id) = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + let Some(agent_id) = record + .get("agentId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| task_ids.contains(*value)) + else { + continue; + }; + candidates + .entry(asset_id.to_string()) + .or_default() + .insert(agent_id.to_string()); + } + candidates + .into_iter() + .filter_map(|(asset_id, agents)| { + (agents.len() == 1).then(|| (asset_id, agents.into_iter().next().unwrap_or_default())) + }) + .collect() +} + +pub(crate) fn build_project_resource_graph( + manifest: &GameCreationAppManifest, + resources: Vec, + agent_db_records: &[serde_json::Value], + producer_mapping_truncated: bool, +) -> ProjectResourceGraphReadModel { + let resource_by_id = resources + .into_iter() + .filter_map(|mut resource| { + resource.resource_id = resource.resource_id.trim().to_string(); + (!resource.resource_id.is_empty()).then_some((resource.resource_id.clone(), resource)) + }) + .collect::>(); + let task_by_id = manifest + .tasks + .iter() + .map(|task| (task.id.clone(), task)) + .collect::>(); + let task_ids = task_by_id.keys().cloned().collect::>(); + let manifest_asset_by_id = manifest + .assets + .iter() + .map(|asset| (asset.id.clone(), asset)) + .collect::>(); + let audit_producer_by_asset_id = if producer_mapping_truncated { + BTreeMap::new() + } else { + audit_asset_producers(agent_db_records, &task_ids) + }; + + let mut resource_ids_by_manifest_asset = BTreeMap::>::new(); + for resource in resource_by_id.values() { + if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| manifest_asset_by_id.contains_key(*asset_id)) + { + resource_ids_by_manifest_asset + .entry(asset_id.to_string()) + .or_default() + .push(resource.resource_id.clone()); + } + } + + let mut producer_by_resource_id = BTreeMap::::new(); + for resource in resource_by_id.values() { + let producer = if let Some(asset_id) = resource + .manifest_asset_id + .as_deref() + .map(str::trim) + .filter(|asset_id| { + resource_ids_by_manifest_asset + .get(*asset_id) + .is_some_and(|resource_ids| resource_ids.len() == 1) + }) { + audit_producer_by_asset_id.get(asset_id).cloned() + } else { + resource + .producer_task_id + .as_deref() + .map(str::trim) + .filter(|task_id| task_ids.contains(*task_id)) + .map(ToOwned::to_owned) + }; + if let Some(producer) = producer { + producer_by_resource_id.insert(resource.resource_id.clone(), producer); + } + } + + let mut resources_by_task = BTreeMap::>::new(); + for (resource_id, task_id) in &producer_by_resource_id { + resources_by_task + .entry(task_id.clone()) + .or_default() + .push(resource_id.clone()); + } + + let mut resources_by_external_id = BTreeMap::>::new(); + for (asset_id, resource_ids) in &resource_ids_by_manifest_asset { + if resource_ids.len() != 1 { + continue; + } + let Some(external_resource_id) = manifest_asset_by_id + .get(asset_id) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + resources_by_external_id + .entry(external_resource_id.to_string()) + .or_default() + .push(resource_ids[0].clone()); + } + + let mut unresolved_reference_resource_ids = BTreeSet::new(); + let mut reference_edge_by_id = BTreeMap::::new(); + for (asset_id, target_resource_ids) in &resource_ids_by_manifest_asset { + if target_resource_ids.len() != 1 { + continue; + } + let Some(asset) = manifest_asset_by_id.get(asset_id) else { + continue; + }; + let target_resource_id = &target_resource_ids[0]; + for external_reference_id in asset + .source + .reference_resource_ids + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + { + let source_candidates = resources_by_external_id + .get(external_reference_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + if source_candidates.len() != 1 { + unresolved_reference_resource_ids.insert(external_reference_id.to_string()); + continue; + } + let source_resource_id = &source_candidates[0]; + if !resource_by_id.contains_key(source_resource_id) + || !resource_by_id.contains_key(target_resource_id) + { + continue; + } + let id = stable_edge_id("asset-reference", source_resource_id, target_resource_id); + reference_edge_by_id.insert( + id.clone(), + ProjectResourceReferenceEdge { + id, + kind: "asset-reference".to_string(), + source_resource_id: source_resource_id.clone(), + target_resource_id: target_resource_id.clone(), + cyclic: false, + }, + ); + } + } + let reference_directed_edges = reference_edge_by_id + .values() + .map(|edge| DirectedEdge { + id: edge.id.clone(), + source_id: edge.source_resource_id.clone(), + target_id: edge.target_resource_id.clone(), + }) + .collect::>(); + let reference_cycles = + analyze_directed_cycles(resource_by_id.keys(), &reference_directed_edges); + let reference_edges = reference_edge_by_id + .into_values() + .map(|mut edge| { + edge.cyclic = reference_cycles.cyclic_edge_ids.contains(&edge.id); + edge + }) + .collect::>(); + + let task_dependency_edges = manifest + .tasks + .iter() + .flat_map(|target_task| { + target_task + .dependencies + .iter() + .collect::>() + .into_iter() + .filter(|source_task_id| task_by_id.contains_key(*source_task_id)) + .map(|source_task_id| DirectedEdge { + id: stable_edge_id("task-flow", source_task_id, &target_task.id), + source_id: source_task_id.clone(), + target_id: target_task.id.clone(), + }) + .collect::>() + }) + .collect::>(); + let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_dependency_depths = + dependency_depth_by_node(&task_cycles, &task_dependency_edges, &BTreeMap::new()); + let minimum_resource_dependency_depths = producer_by_resource_id + .iter() + .filter_map(|(resource_id, task_id)| { + task_dependency_depths + .get(task_id) + .copied() + .map(|depth| (resource_id.clone(), depth)) + }) + .collect::>(); + let resource_dependency_depths = dependency_depth_by_node( + &reference_cycles, + &reference_directed_edges, + &minimum_resource_dependency_depths, + ); + let task_flows = task_dependency_edges + .iter() + .filter_map(|edge| { + let source_resource_ids = resources_by_task.get(&edge.source_id)?; + let target_resource_ids = resources_by_task.get(&edge.target_id)?; + (!source_resource_ids.is_empty() && !target_resource_ids.is_empty()).then(|| { + ProjectResourceTaskFlow { + id: edge.id.clone(), + kind: "task-flow".to_string(), + source_task_id: edge.source_id.clone(), + target_task_id: edge.target_id.clone(), + source_resource_ids: source_resource_ids.clone(), + target_resource_ids: target_resource_ids.clone(), + cyclic: task_cycles.cyclic_edge_ids.contains(&edge.id), + } + }) + }) + .collect::>(); + + let mut connection_by_resource_id = resource_by_id + .keys() + .map(|resource_id| (resource_id.clone(), MutableConnectionIndex::default())) + .collect::>(); + for edge in &reference_edges { + if let Some(target) = connection_by_resource_id.get_mut(&edge.target_resource_id) { + target + .upstream_reference_resource_ids + .insert(edge.source_resource_id.clone()); + target.reference_edge_ids.insert(edge.id.clone()); + } + if let Some(source) = connection_by_resource_id.get_mut(&edge.source_resource_id) { + source + .downstream_reference_resource_ids + .insert(edge.target_resource_id.clone()); + source.reference_edge_ids.insert(edge.id.clone()); + } + } + for flow in &task_flows { + for resource_id in flow + .source_resource_ids + .iter() + .chain(flow.target_resource_ids.iter()) + { + if let Some(index) = connection_by_resource_id.get_mut(resource_id) { + index.task_flow_ids.insert(flow.id.clone()); + } + } + } + + ProjectResourceGraphReadModel { + resource_ids: resource_by_id.keys().cloned().collect(), + reference_edges, + task_flows, + connection_index: connection_by_resource_id + .into_iter() + .map(|(resource_id, index)| ProjectResourceConnectionIndex { + resource_id, + upstream_reference_resource_ids: index + .upstream_reference_resource_ids + .into_iter() + .collect(), + downstream_reference_resource_ids: index + .downstream_reference_resource_ids + .into_iter() + .collect(), + reference_edge_ids: index.reference_edge_ids.into_iter().collect(), + task_flow_ids: index.task_flow_ids.into_iter().collect(), + }) + .collect(), + producer_assignments: producer_by_resource_id + .into_iter() + .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { + resource_id, + task_id, + }) + .collect(), + dependency_depths: resource_dependency_depths + .into_iter() + .map( + |(resource_id, dependency_depth)| ProjectResourceDependencyDepth { + resource_id, + dependency_depth, + }, + ) + .collect(), + unresolved_reference_resource_ids: unresolved_reference_resource_ids.into_iter().collect(), + cyclic_resource_ids: reference_cycles.cyclic_node_ids.into_iter().collect(), + cyclic_task_ids: task_cycles.cyclic_node_ids.into_iter().collect(), + producer_mapping_truncated, + } +} + +pub(crate) fn read_project_resource_graph_at( + root: &Path, + expected_project_id: &str, + resources: Vec, +) -> Result { + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != expected_project_id { + return Err("资源依赖图项目身份不匹配".to_string()); + } + let (records, truncated) = + read_agent_db_records_bounded(root, RESOURCE_GRAPH_AGENT_DB_READ_BYTES)?; + Ok(build_project_resource_graph( + &manifest, resources, &records, truncated, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource( + resource_id: &str, + manifest_asset_id: Option<&str>, + producer_task_id: Option<&str>, + ) -> ProjectResourceGraphNodeInput { + ProjectResourceGraphNodeInput { + resource_id: resource_id.to_string(), + manifest_asset_id: manifest_asset_id.map(ToOwned::to_owned), + producer_task_id: producer_task_id.map(ToOwned::to_owned), + } + } + + fn asset( + id: &str, + external_resource_id: Option<&str>, + references: &[&str], + external_task_id: Option<&str>, + ) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "test".to_string(), + media_type: "image/png".to_string(), + local_path: format!("assets/{id}.png"), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: external_resource_id.map(ToOwned::to_owned), + asset_object_id: None, + task_id: external_task_id.map(ToOwned::to_owned), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: references.iter().map(|value| value.to_string()).collect(), + }, + } + } + + fn task(id: &str, dependencies: &[&str]) -> GameCreationAppTaskState { + GameCreationAppTaskState { + id: id.to_string(), + title: id.to_string(), + group: GameCreationAppAgentGroup::Art, + role: "test".to_string(), + dependencies: dependencies.iter().map(|value| value.to_string()).collect(), + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + } + + fn manifest( + tasks: Vec, + assets: Vec, + ) -> GameCreationAppManifest { + let mut manifest = new_game_creation_app_manifest("graph-project", "Graph project"); + manifest.tasks = tasks; + manifest.assets = assets; + manifest + } + + #[test] + fn graph_uses_runtime_agent_identity_instead_of_external_task_id() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let records = vec![ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.task_flows.len(), 1); + assert_eq!(graph.task_flows[0].source_task_id, "art-director"); + assert_eq!(graph.task_flows[0].target_task_id, "design-foundation"); + assert_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.task_id.as_str())) + .collect::>(), + BTreeMap::from([ + ("asset:spec", "art-director"), + ("asset:ui", "design-foundation"), + ]), + ); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + assert!(graph + .producer_assignments + .iter() + .all(|assignment| assignment.task_id != "task-1" && assignment.task_id != "task-2")); + } + + #[test] + fn graph_omits_task_flow_without_reliable_runtime_producer_evidence() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("art-director")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("design-foundation"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[], + false, + ); + + assert_eq!(graph.reference_edges.len(), 1); + assert!(graph.task_flows.is_empty()); + assert!(graph.producer_assignments.is_empty()); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_fails_closed_for_audit_producers_when_agent_db_tail_is_truncated() { + let manifest = manifest( + vec![ + task("art-director", &[]), + task("design-foundation", &["art-director"]), + ], + vec![ + asset("spec", Some("external-spec"), &[], Some("task-1")), + asset( + "ui", + Some("external-ui"), + &["external-spec"], + Some("task-2"), + ), + ], + ); + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:spec", Some("spec"), None), + resource("asset:ui", Some("ui"), None), + ], + &[ + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "spec", + "agentId": "art-director" + }), + serde_json::json!({ + "recordType": "agent.runtime.canvas.asset_generate", + "assetId": "ui", + "agentId": "design-foundation" + }), + ], + true, + ); + + assert!(graph.producer_mapping_truncated); + assert!(graph.producer_assignments.is_empty()); + assert!(graph.task_flows.is_empty()); + assert_eq!(graph.reference_edges.len(), 1); + assert_eq!(graph.reference_edges[0].source_resource_id, "asset:spec"); + assert_eq!(graph.reference_edges[0].target_resource_id, "asset:ui"); + assert_eq!( + graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); + } + + #[test] + fn graph_aggregates_flows_filters_missing_resources_and_detects_cycles_iteratively() { + let manifest = manifest( + vec![task("task-a", &["task-b"]), task("task-b", &["task-a"])], + vec![ + asset( + "a", + Some("external-a"), + &["external-b", "missing"], + Some("task-1"), + ), + asset("b", Some("external-b"), &["external-a"], Some("task-2")), + ], + ); + let records = vec![ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "a", "agentId": "task-a"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "b", "agentId": "task-b"}), + ]; + let graph = build_project_resource_graph( + &manifest, + vec![ + resource("asset:a", Some("a"), None), + resource("asset:b", Some("b"), None), + resource("task-a:artifact", None, Some("task-a")), + resource("task-b:artifact", None, Some("task-b")), + ], + &records, + false, + ); + + assert_eq!(graph.reference_edges.len(), 2); + assert!(graph.reference_edges.iter().all(|edge| edge.cyclic)); + assert_eq!(graph.task_flows.len(), 2); + assert!(graph.task_flows.iter().all(|flow| flow.cyclic)); + assert_eq!(graph.unresolved_reference_resource_ids, vec!["missing"]); + assert_eq!(graph.cyclic_resource_ids, vec!["asset:a", "asset:b"]); + assert_eq!(graph.cyclic_task_ids, vec!["task-a", "task-b"]); + assert!( + graph + .task_flows + .iter() + .all(|flow| flow.source_resource_ids.len() == 2 + && flow.target_resource_ids.len() == 2) + ); + } + + #[test] + fn graph_handles_4096_task_chain_without_recursive_traversal_or_cartesian_edges() { + let tasks = (0..4096) + .map(|index| { + let id = format!("task:{index}"); + let dependencies = if index == 0 { + Vec::new() + } else { + vec![format!("task:{}", index - 1)] + }; + GameCreationAppTaskState { + id: id.clone(), + title: id, + group: GameCreationAppAgentGroup::Code, + role: "test".to_string(), + dependencies, + artifacts: Vec::new(), + acceptance_criteria: Vec::new(), + status: GameCreationAppTaskStatus::Completed, + } + }) + .collect::>(); + let resources = (0..4096) + .map(|index| { + resource( + &format!("resource:{index}"), + None, + Some(&format!("task:{index}")), + ) + }) + .collect::>(); + let graph = + build_project_resource_graph(&manifest(tasks, Vec::new()), resources, &[], false); + + assert_eq!(graph.task_flows.len(), 4095); + assert_eq!(graph.connection_index.len(), 4096); + assert!(graph + .connection_index + .iter() + .all(|index| index.task_flow_ids.len() <= 2)); + assert_eq!( + graph + .dependency_depths + .iter() + .find(|depth| depth.resource_id == "resource:4095") + .map(|depth| depth.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); + } + + #[test] + fn dependency_depth_uses_reference_sccs_after_task_depth_floors() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source-task", &[]), + task("late-task", &["source-task"]), + ], + vec![ + asset("base", Some("external-base"), &[], None), + asset( + "cycle-a", + Some("external-cycle-a"), + &["external-base", "external-cycle-b"], + None, + ), + asset( + "cycle-b", + Some("external-cycle-b"), + &["external-cycle-a"], + None, + ), + asset( + "target", + Some("external-target"), + &["external-cycle-b"], + None, + ), + ], + ), + vec![ + resource("asset:base", Some("base"), None), + resource("asset:cycle-a", Some("cycle-a"), None), + resource("asset:cycle-b", Some("cycle-b"), None), + resource("asset:target", Some("target"), None), + ], + &[ + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "base", "agentId": "source-task"}), + serde_json::json!({"recordType": "agent.runtime.canvas.asset_generate", "assetId": "cycle-a", "agentId": "late-task"}), + ], + false, + ); + + let depths = graph + .dependency_depths + .iter() + .map(|depth| (depth.resource_id.as_str(), depth.dependency_depth)) + .collect::>(); + assert_eq!(depths["asset:base"], 0); + assert_eq!(depths["asset:cycle-a"], 1); + assert_eq!(depths["asset:cycle-b"], 1); + assert_eq!(depths["asset:target"], 2); + assert_eq!( + graph.cyclic_resource_ids, + vec!["asset:cycle-a", "asset:cycle-b"] + ); + assert_eq!(graph.task_flows.len(), 1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs new file mode 100644 index 000000000..129b01f75 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -0,0 +1,441 @@ +use crate::image_inspect::{ + same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors, +}; +use crate::project::{ + normalize_relative_path, open_project_snapshot_regular_file, + reject_sensitive_project_file_read, resolve_local_project_path, +}; +use base64::Engine as _; +use serde::Serialize; +use std::io::Read; +use std::path::Path; + +const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectTextPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) content: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectMediaPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + pub(crate) data_url: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectMediaPreviewKind { + Art, + Audio, +} + +pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml") + ) && (media_type.is_empty() + || media_type.starts_with("text/") + || media_type.contains("json") + || media_type.contains("yaml") + || matches!( + media_type.as_str(), + "项目文档" | "application/toml" | "application/mdx" + )) +} + +pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + ) || media_type.starts_with("video/") + || media_type == "image/svg+xml" +} + +pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus") + ) || media_type.starts_with("audio/") +} + +pub(crate) fn load_local_project_text_preview( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let media_type = project_text_media_type(&normalized) + .ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "项目文档", + )?; + let content = + String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?; + Ok(LocalProjectTextPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: content.len() as u64, + content, + }) +} + +pub(crate) fn load_local_project_media_preview( + root: &Path, + relative_path: &str, + kind: ProjectMediaPreviewKind, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, + "项目媒体资源", + )?; + if bytes.is_empty() { + return Err("媒体文件为空,无法预览".to_string()); + } + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + Ok(LocalProjectMediaPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len: bytes.len() as u64, + data_url: format!( + "data:{media_type};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + }) +} + +fn read_stable_project_resource( + root: &Path, + normalized: &str, + max_bytes: u64, + label: &str, +) -> Result, String> { + let absolute = resolve_local_project_path(root, normalized)?; + validate_agent_runtime_inspection_ancestors(root, &absolute)?; + let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if initial_metadata.len() > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let mut bytes = Vec::with_capacity(initial_metadata.len() as usize); + file.by_ref() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?; + if bytes.len() as u64 > max_bytes { + return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); + } + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?; + if initial_metadata.len() != bytes.len() as u64 + || final_metadata.len() != bytes.len() as u64 + || !same_open_file_snapshot(&initial_metadata, &final_metadata) + { + return Err(format!("{label}读取期间发生漂移:{normalized}")); + } + let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { + return Err(format!("{label}路径读取期间发生替换:{normalized}")); + } + Ok(bytes) +} + +fn project_text_media_type(path: &str) -> Option<&'static str> { + match path_extension(path).as_deref()? { + "md" | "markdown" | "mdx" => Some("text/markdown"), + "txt" => Some("text/plain"), + "json" => Some("application/json"), + "yaml" | "yml" => Some("application/yaml"), + "toml" => Some("application/toml"), + _ => None, + } +} + +fn detect_project_media_type( + path: &str, + bytes: &[u8], + kind: ProjectMediaPreviewKind, +) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { + validate_safe_svg(bytes)?; + return Ok("image/svg+xml"); + } + if kind == ProjectMediaPreviewKind::Art { + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Ok("image/gif"); + } + if bytes.starts_with(b"BM") { + return Ok("image/bmp"); + } + if is_avif(bytes) { + return Ok("image/avif"); + } + if is_iso_base_media(bytes) { + return Ok(if path_extension(path).as_deref() == Some("mov") { + "video/quicktime" + } else { + "video/mp4" + }); + } + if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) { + return Ok("video/webm"); + } + return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string()); + } + + if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) { + Ok("audio/mpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" { + Ok("audio/wav") + } else if bytes.starts_with(b"OggS") { + Ok("audio/ogg") + } else if bytes.starts_with(b"fLaC") { + Ok("audio/flac") + } else if is_avif(bytes) { + Err("音乐音效文件签名与登记类型不一致".to_string()) + } else if is_iso_base_media(bytes) { + Ok("audio/mp4") + } else if looks_like_aac_adts(bytes) { + Ok("audio/aac") + } else { + Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string()) + } +} + +fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> { + let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?; + let lower = text.to_ascii_lowercase(); + if !lower.contains(" bool { + let mut remaining = text; + while let Some(index) = remaining.find("href") { + let after_name = &remaining[index + 4..]; + let Some(after_equals) = after_name.trim_start().strip_prefix('=') else { + remaining = after_name; + continue; + }; + let value = after_equals.trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = after_name; + } + false +} + +fn contains_unsafe_svg_url(text: &str) -> bool { + let mut remaining = text; + while let Some(index) = remaining.find("url(") { + let value = remaining[index + 4..].trim_start(); + let value = value + .strip_prefix('\'') + .or_else(|| value.strip_prefix('"')) + .unwrap_or(value) + .trim_start(); + if !value.starts_with('#') { + return true; + } + remaining = &remaining[index + 4..]; + } + false +} + +fn contains_svg_event_handler(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0usize; + while index + 3 < bytes.len() { + if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") { + let mut cursor = index + 3; + while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor < bytes.len() && bytes[cursor] == b'=' { + return true; + } + } + index += 1; + } + false +} + +fn is_iso_base_media(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[4..8] == b"ftyp" +} + +fn is_avif(bytes: &[u8]) -> bool { + is_iso_base_media(bytes) + && (&bytes[8..12] == b"avif" + || &bytes[8..12] == b"avis" + || bytes[8..].windows(4).any(|brand| brand == b"avif")) +} + +fn looks_like_mp3_frame(bytes: &[u8]) -> bool { + bytes.len() >= 4 + && bytes[0] == 0xff + && bytes[1] & 0xe0 == 0xe0 + && bytes[1] & 0x06 != 0 + && bytes[2] & 0xf0 != 0xf0 + && bytes[2] & 0x0c != 0x0c +} + +fn looks_like_id3(bytes: &[u8]) -> bool { + if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff { + return false; + } + let size_bytes = &bytes[6..10]; + if size_bytes.iter().any(|byte| byte & 0x80 != 0) { + return false; + } + let tag_size = size_bytes + .iter() + .fold(0usize, |size, byte| (size << 7) | usize::from(*byte)); + 10usize + .checked_add(tag_size) + .is_some_and(|required| required <= bytes.len()) +} + +fn looks_like_aac_adts(bytes: &[u8]) -> bool { + bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0 +} + +fn path_extension(path: &str) -> Option { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn text_preview_requires_utf8_and_a_supported_extension() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); + fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); + fs::write(root.path().join("docs/page.html"), "

unsafe

").expect("html"); + + let preview = + load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown"); + assert_eq!(preview.media_type, "text/markdown"); + assert!(preview.content.contains("正文")); + assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err()); + } + + #[test] + fn media_preview_accepts_safe_svg_and_rejects_active_svg() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + fs::write( + root.path().join("assets/icon.svg"), + "", + ) + .expect("svg"); + fs::write( + root.path().join("assets/active.svg"), + "", + ) + .expect("active svg"); + fs::write( + root.path().join("assets/external.svg"), + "", + ) + .expect("external svg"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/icon.svg", + ProjectMediaPreviewKind::Art, + ) + .expect("safe svg"); + assert_eq!(preview.media_type, "image/svg+xml"); + assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); + assert!(load_local_project_media_preview( + root.path(), + "assets/active.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + assert!(load_local_project_media_preview( + root.path(), + "assets/external.svg", + ProjectMediaPreviewKind::Art, + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn resource_preview_rejects_symlink_and_hardlink_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temp root"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir_all(root.path().join("docs")).expect("docs dir"); + let source = outside.path().join("source.md"); + fs::write(&source, "secret").expect("source"); + symlink(&source, root.path().join("docs/link.md")).expect("symlink"); + fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink"); + + assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err()); + assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err()); + } +} 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..e4d35d654 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; @@ -8,6 +11,7 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -16,6 +20,76 @@ const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15); +#[derive(Default)] +pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { + generation: u64, + registration: Option, +} + +struct ExternalAgentRunnerGuiOwnerRegistration { + generation: u64, + config_dir: PathBuf, + params: ExternalAgentRunnerRequestParams, + attached_boot_id: Option, +} + +static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< + Mutex, +> = OnceLock::new(); + +fn external_agent_runner_gui_owner_attachment_state( +) -> &'static Mutex { + EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE + .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) +} + +pub(super) fn register_external_agent_runner_gui_owner_attachment( + state: &Mutex, + config_dir: &Path, + params: ExternalAgentRunnerRequestParams, +) { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + let generation = state.generation; + state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { + generation, + config_dir: config_dir.to_path_buf(), + params, + attached_boot_id: None, + }); +} + +pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( + state: &Mutex, + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + attach: F, +) -> Result<(), String> +where + F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, +{ + let Some((generation, params)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| (registration.generation, registration.params.clone())) + }) + }) else { + return Ok(()); + }; + + attach(endpoint, params)?; + + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation && registration.config_dir == config_dir { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + Ok(()) +} + fn redact_url_queries(line: &str) -> String { line.split_whitespace() .map(|token| { @@ -911,24 +985,58 @@ 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 _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; - let endpoint = ensure_external_agent_runner(&config_dir)?; - let result = send_external_agent_runner_request( - &endpoint, - "runner.attach_gui_owner", - ExternalAgentRunnerRequestParams::default(), - )?; - if result.get("attached").and_then(Value::as_bool) == Some(true) { + register_external_agent_runner_gui_owner_attachment( + external_agent_runner_gui_owner_attachment_state(), + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink.port), + event_sink_token: Some(event_sink.token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + ensure_external_agent_runner(&config_dir).map(|_| ()) +} + +pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( + result: &Value, +) -> Result<(), String> { + 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()) } } +fn attach_external_agent_runner_gui_owner_at( + endpoint: &ExternalAgentRunnerEndpoint, + params: ExternalAgentRunnerRequestParams, +) -> Result<(), String> { + let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; + validate_external_agent_runner_gui_owner_attachment_result(&result) +} + +fn attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result<(), String> { + attach_registered_external_agent_runner_gui_owner_if_needed_with( + external_agent_runner_gui_owner_attachment_state(), + config_dir, + endpoint, + attach_external_agent_runner_gui_owner_at, + ) +} + pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, ) -> Result { @@ -1024,6 +1132,9 @@ pub(super) fn ensure_external_agent_runner( match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { ExternalAgentRunnerReuseDecision::Reuse => { if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; return Ok(endpoint); } } @@ -1057,6 +1168,7 @@ pub(super) fn ensure_external_agent_runner( let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; + attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?; Ok(endpoint) } Err(error) => { @@ -1181,6 +1293,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..4e28d62ac 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 @@ -10,6 +10,7 @@ use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use std::time::{Duration, Instant}; static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -84,7 +85,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 { @@ -551,6 +552,292 @@ fn runner_endpoint_rejects_hard_links() { assert!(error.contains("硬链接")); } +#[test] +fn gui_owner_registration_replays_once_for_each_runner_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("registered-gui-appdata"); + let event_sink_port = 31_317; + let event_sink_token = "a".repeat(64); + let params = ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink_port), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }; + register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); + + let calls = std::cell::RefCell::new(Vec::new()); + let endpoint_a = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-a", + 31318, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params + .event_sink_port + .expect("registered sink port is retained"), + params + .event_sink_token + .expect("registered sink token is retained"), + )); + Ok(()) + }, + ) + .expect("first boot attaches"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |_, _| panic!("same boot must not attach twice"), + ) + .expect("same boot is idempotent"); + + let endpoint_b = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-b", + 31319, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_b, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params + .event_sink_port + .expect("registered sink port is replayed"), + params + .event_sink_token + .expect("registered sink token is replayed"), + )); + Ok(()) + }, + ) + .expect("replacement boot reattaches"); + + assert_eq!( + calls.into_inner(), + vec![ + ( + "gui-owner-boot-a".to_string(), + event_sink_port, + event_sink_token.clone(), + ), + ( + "gui-owner-boot-b".to_string(), + event_sink_port, + event_sink_token, + ), + ] + ); +} + +#[test] +fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("retry-gui-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams::default(), + ); + let endpoint = test_endpoint( + "gui-owner-retry-token-gui-owner-retry-token", + "gui-owner-retry-boot", + 31320, + ); + let attempts = std::cell::Cell::new(0_u32); + + let error = attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Err("injected attach failure".to_string()) + }, + ) + .expect_err("failed attach must remain pending"); + assert_eq!(error, "injected attach failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Ok(()) + }, + ) + .expect("same boot retries after failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| panic!("successful retry must mark the boot attached"), + ) + .expect("successful retry is idempotent"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("missing-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_322), + event_sink_token: Some("c".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "missing-sink-confirmation-runner-token", + "missing-sink-confirmation-boot", + 31_322, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true + })) + }, + ) + .expect_err("missing eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after missing event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("false-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_323), + event_sink_token: Some("d".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "false-sink-confirmation-runner-token", + "false-sink-confirmation-boot", + 31_323, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": false + })) + }, + ) + .expect_err("false eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after false event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_does_not_cross_config_dirs() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let registered_config_dir = PathBuf::from("registered-gui-appdata"); + let other_config_dir = PathBuf::from("other-gui-appdata"); + let event_sink_token = "e".repeat(64); + register_external_agent_runner_gui_owner_attachment( + &state, + ®istered_config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_324), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "gui-owner-config-token-gui-owner-config-token", + "gui-owner-config-boot", + 31321, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &other_config_dir, + &endpoint, + |_, _| panic!("GUI owner registration must stay bound to its AppData"), + ) + .expect("other AppData remains unattached"); + + let calls = std::cell::Cell::new(0_u32); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + ®istered_config_dir, + &endpoint, + |_, params| { + calls.set(calls.get() + 1); + assert_eq!(params.event_sink_port, Some(31_324)); + assert_eq!( + params.event_sink_token.as_deref(), + Some(event_sink_token.as_str()) + ); + Ok(()) + }, + ) + .expect("registered AppData attaches"); + assert_eq!(calls.get(), 1); + + let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &unregistered, + ®istered_config_dir, + &endpoint, + |_, _| panic!("CLI state without GUI registration must not attach"), + ) + .expect("unregistered CLI state remains unchanged"); +} + #[test] fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { let directory = unique_test_directory(); @@ -567,7 +854,9 @@ fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { } #[test] -fn attached_gui_owner_loss_forces_runner_shutdown() { +fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() { + let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); let token = "gui-owner-monitor-token-gui-owner-monitor-token"; @@ -583,12 +872,23 @@ 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, ); assert!(attached.ok); assert!(state.gui_owner_attached.load(Ordering::Acquire)); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }) + ); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); @@ -599,6 +899,9 @@ 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)); + drop(sink_guard); + let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(cleanup_guard.configured_sink(), None); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index a255f8748..8365f3873 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser .expect("read barrier after rejecting reserved delivery") .is_clear()); + let released = wait_for_agent_runtime_lane_release_async( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .await; + assert_eq!(released.state.run_id, parent_run_id); + fs::remove_dir_all(root).ok(); } 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 136e1097f..0fac08451 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 @@ -3,15 +3,159 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; 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(()); +const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; + +fn read_manifest_invalidation_relay_payload_with_deadline( + listener: &TcpListener, +) -> io::Result> { + listener.set_nonblocking(true)?; + let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT; + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay accept timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + }; + + stream.set_nonblocking(true)?; + let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT; + let mut payload = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => return Ok(payload), + Ok(read) => { + payload.extend_from_slice(&buffer[..read]); + if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest invalidation relay payload exceeded test limit", + )); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= payload_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay payload timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} + +#[test] +fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + 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); + sink_guard + .configure(relay_port, &relay_token) + .expect("configure manifest invalidation relay fixture"); + emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); + let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener) + .expect("receive manifest invalidation relay within deadline"); + 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 manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() { + let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation cleanup fixture"); + let cleanup_port = cleanup_listener + .local_addr() + .expect("read manifest invalidation cleanup fixture address") + .port(); + let cleanup_token = "b".repeat(64); + let unwind = std::panic::catch_unwind(|| { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + sink_guard + .configure(cleanup_port, &cleanup_token) + .expect("configure manifest invalidation cleanup fixture"); + assert_eq!( + sink_guard.configured_sink(), + Some(GameCreatorManifestInvalidationEventSink { + port: cleanup_port, + token: cleanup_token.clone(), + }) + ); + panic!("exercise manifest invalidation sink guard unwind cleanup"); + }); + assert!(unwind.is_err()); + + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); + + let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind empty manifest invalidation relay fixture"); + let accept_started = Instant::now(); + let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener) + .expect_err("missing relay must time out"); + assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut); + assert!(accept_started.elapsed() < Duration::from_secs(2)); + + let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind stalled manifest invalidation relay fixture"); + let stalled_stream = TcpStream::connect( + stalled_listener + .local_addr() + .expect("read stalled manifest invalidation relay fixture address"), + ) + .expect("connect stalled manifest invalidation relay fixture"); + let payload_started = Instant::now(); + let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener) + .expect_err("incomplete relay payload must time out"); + assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut); + assert!(payload_started.elapsed() < Duration::from_secs(2)); + drop(stalled_stream); +} #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { @@ -143,6 +287,16 @@ fn unique_project_path() -> PathBuf { )) } +pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { + let temp_root = std::env::temp_dir() + .canonicalize() + .expect("canonicalize test temp root"); + tempfile::Builder::new() + .prefix(prefix) + .tempdir_in(temp_root) + .expect("create test temp directory under canonical root") +} + fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf { let path_key = |value: &str| { format!("{:x}", Sha256::digest(value.as_bytes())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 4ff135ddf..141878dab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -6090,3 +6090,107 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { fs::remove_dir_all(root).ok(); } + +#[test] +fn local_project_resource_previews_require_registered_safe_resources() { + let root = unique_project_path(); + init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目") + .expect("project init"); + fs::create_dir_all(root.join("assets")).expect("asset dir"); + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document"); + fs::write( + root.join("assets/icon.svg"), + "", + ) + .expect("svg resource"); + fs::write( + root.join("assets/bgm.mp3"), + [b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0], + ) + .expect("audio resource"); + fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document"); + + let source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + register_local_asset_at( + &root, + "game/design.md", + "design-document", + "text/markdown", + "generated", + source(), + ) + .expect("register document"); + register_local_asset_at( + &root, + "assets/icon.svg", + "icon", + "image/svg+xml", + "generated", + source(), + ) + .expect("register svg"); + register_local_asset_at( + &root, + "assets/bgm.mp3", + "bgm", + "audio/mpeg", + "generated", + source(), + ) + .expect("register audio"); + + let document = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/design.md".to_string(), + ) + .expect("read registered document"); + assert_eq!(document.media_type, "text/markdown"); + assert!(document.content.contains("安全正文")); + + let svg = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/icon.svg".to_string(), + "art".to_string(), + ) + .expect("read registered svg"); + assert_eq!(svg.media_type, "image/svg+xml"); + let audio = read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "audio".to_string(), + ) + .expect("read registered audio"); + assert_eq!(audio.media_type, "audio/mpeg"); + + let unregistered_error = read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "game/unregistered.md".to_string(), + ) + .expect_err("unregistered document rejected"); + assert!(unregistered_error.contains("已登记的文档资源")); + assert!(read_local_project_text_preview( + root.to_string_lossy().into_owned(), + "../outside.md".to_string(), + ) + .is_err()); + assert!(read_local_project_media_preview( + root.to_string_lossy().into_owned(), + "assets/bgm.mp3".to_string(), + "art".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 040b2c83b..077839dc5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -3008,11 +3008,23 @@ async fn background_task_recovers_when_assistant_message_cannot_persist() { .messages .iter() .all(|message| message.role != "assistant")); - let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); - let audit_records = agent_db - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .collect::>(); + let mut audit_records = Vec::new(); + for _ in 0..100 { + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + audit_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + if audit_records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.finalization_pending") + && record.get("runId").and_then(Value::as_str) + == Some("assistant-conversation-write-failure-run") + }) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert!(audit_records.iter().any(|record| { record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.background_task.finalization_pending") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index aca14fac6..8089cbc32 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, @@ -566,6 +567,10 @@ type AppProps = { supervisorChatOnly?: boolean; gameChatOnly?: boolean; initialSupervisorMessage?: string; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -580,6 +585,7 @@ export function App({ supervisorChatOnly = false, gameChatOnly = false, initialSupervisorMessage = '', + onManifestChange, onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -607,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, ); @@ -813,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< | (( @@ -1301,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, [ @@ -1390,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; @@ -5628,6 +5751,7 @@ export function App({ PROJECT_SUPERVISOR_AGENT_ID, sessionId, submissionRunProfile, + 'project-supervisor-game-chat', ) : null; let autoPreviewAfterRevision = 0; @@ -9983,25 +10107,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) ?? '', @@ -10441,6 +10546,18 @@ export function App({ const professionalResultCandidateKey = professionalResultCandidates .map((candidate) => `${candidate.agentId}:${candidate.runtimeUpdatedAt}`) .join('|'); + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + if (!projectSupervisorOnly || !nextProjectPath || !onManifestChange) { + return; + } + onManifestChange(nextProjectPath, manifest); + }, [ + localProject?.projectPath, + manifest, + onManifestChange, + projectSupervisorOnly, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; 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/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 65a2a05d4..32e2714c7 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -725,6 +725,7 @@ export function matchingAgentRuntimeForSteer( requestedRunProfile: NonNullable< AgentRuntimeState['runProfile'] > = 'standard', + requestedSource?: string, ) { if (!sessionId) { return null; @@ -735,6 +736,7 @@ export function matchingAgentRuntimeForSteer( runtime?.agentId === agentId && runtime.sessionId === sessionId && (runtime.runProfile ?? 'standard') === requestedRunProfile && + (!requestedSource || runtime.source === requestedSource) && isAgentRuntimeSteerableState(runtime), ) ?? null ); @@ -834,6 +836,7 @@ export async function submitProjectSupervisorRuntimeTask({ PROJECT_SUPERVISOR_AGENT_ID, sessionId, runProfile, + source, ); if (steerRuntime) { const steer = await invoke( @@ -1330,7 +1333,9 @@ export function projectSupervisorCollaboratingAgentRuntimes( const runtimesByAgentId = new Map(); for (const runtime of Object.values(runtimeByAgentId)) { const isVisibleChildSource = - ['agent-delegate', 'agent-delegate-retry'].includes(runtime?.source ?? '') || + ['agent-delegate', 'agent-delegate-retry'].includes( + runtime?.source ?? '', + ) || (supervisorRuntime.source === 'project-supervisor-game-chat' && runtime?.source === 'agent-ready-task-scheduler'); if ( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e9a84d703..7cd31249c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import { launcherNotifications } from '../../app/constants'; import { closeDialogOnEscape } from '../../app/dialogs'; @@ -45,6 +45,7 @@ export function WorkspaceLauncherShell({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, @@ -55,6 +56,24 @@ export function WorkspaceLauncherShell({ createHomeDraft, openProject, } = homeProject; + const syncActiveProjectManifest = useCallback( + ( + sourceProjectPath: string, + manifest: NonNullable['manifest'], + ) => { + setCurrentProjectContext((current) => { + if ( + !current || + current.projectPath !== sourceProjectPath || + current.manifest === manifest + ) { + return current; + } + return { ...current, manifest }; + }); + }, + [setCurrentProjectContext], + ); function showLauncherNotice(title: string) { setLauncherNotice({ @@ -163,6 +182,7 @@ export function WorkspaceLauncherShell({ initialProjectPath={currentProjectContext.projectPath} initialProjectManifest={currentProjectContext.manifest} projectSupervisorOnly + onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 624d2bec5..c615e387d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -34,6 +34,10 @@ export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; + onManifestChange?: ( + projectPath: string, + manifest: GameCreationAppManifest, + ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 9d15570cb..055d266f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -430,6 +430,7 @@ export function useHomeProjectCreation({ projectPath, setProjectPath, currentProjectContext, + setCurrentProjectContext, activeProjectPreview, setActiveProjectPreview, activeProjectAgentRuntimeSummaries, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0cfe726c2..1573fb5d1 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4098,8 +4098,6 @@ iframe.preview-frame { .game-resource-canvas { position: relative; - display: grid; - align-content: start; flex: 1; min-height: 0; padding: 12px; @@ -4109,7 +4107,84 @@ iframe.preview-frame { background-size: 18px 18px; } +.game-resource-canvas-content { + position: relative; + display: grid; + align-content: start; + width: max-content; + min-width: 100%; + min-height: 100%; +} + +.game-resource-dependency-overlay { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.game-resource-dependency-description { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.game-resource-dependency-edge, +.game-resource-dependency-edge path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} + +.game-resource-dependency-edge--reference { + stroke: #c45f20; + stroke-width: 2.4px; + opacity: 1; +} + +.game-resource-dependency-edge--task path { + stroke: #918b87; + stroke-width: 1.4px; + stroke-dasharray: 4 7; +} + +.game-resource-dependency-edge--task .game-resource-dependency-trunk { + stroke-width: 1.7px; + opacity: 0.88; +} + +.game-resource-dependency-edge--task .game-resource-dependency-branch { + opacity: 0.72; +} + +.game-resource-dependency-edge.is-cyclic, +.game-resource-dependency-edge.is-cyclic path { + stroke-dashoffset: 4; +} + +.game-resource-dependency-marker--reference path { + fill: #c45f20; + stroke-linejoin: round; +} + +.game-resource-dependency-marker--task path { + fill: #918b87; + stroke-linejoin: round; +} + .game-resource-section { + position: relative; + z-index: 1; display: grid; gap: 10px; min-width: 620px; @@ -4181,11 +4256,10 @@ iframe.preview-frame { color: #4e382f; text-align: left; box-shadow: 0 6px 18px rgb(96 62 47 / 6%); - cursor: grab; - touch-action: none; + cursor: pointer; + touch-action: manipulation; user-select: none; transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0); - will-change: transform; } .game-resource-card:hover, @@ -4196,13 +4270,11 @@ iframe.preview-frame { box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } -.game-resource-card.is-dragging { - z-index: 2; - opacity: 0.72; - cursor: grabbing; +.game-resource-card.is-relation-version-binding { + border-color: #d87342; box-shadow: - 0 12px 28px rgb(195 105 62 / 24%), - 0 0 0 2px rgb(213 123 81 / 18%); + 0 8px 22px rgb(195 105 62 / 18%), + 0 0 0 2px rgb(216 115 66 / 14%); } .game-resource-card-icon { @@ -4233,38 +4305,16 @@ iframe.preview-frame { font-size: 9px; } -.game-resource-focus-layer { - position: fixed; - inset: 0; - z-index: 240; - pointer-events: none; -} - .game-resource-focus { - position: fixed; - left: 50%; - top: 50%; display: grid; grid-template-rows: auto minmax(0, 1fr); - width: min(520px, calc(100vw - 108px)); - max-height: min(620px, calc(100dvh - 190px)); + width: 100%; + min-width: 0; + min-height: 0; overflow: hidden; - border: 1px solid #dda07e; - border-radius: 16px; - background: rgb(255 251 247 / 97%); + background: #fffdfa; color: #563b31; - box-shadow: 0 20px 42px rgb(84 49 34 / 18%); outline: 0; - pointer-events: auto; - transform: translate(-50%, -50%); -} - -.game-resource-focus[style] { - transform: none; -} - -.game-resource-focus--art { - width: min(760px, calc(100vw - 140px)); } .game-resource-focus-titlebar { @@ -4273,16 +4323,9 @@ iframe.preview-frame { justify-content: space-between; gap: 12px; min-height: 58px; - padding: 10px 12px 10px 14px; + padding: 10px 14px 10px 16px; border-bottom: 1px solid #ead8cf; background: #fff8f3; - cursor: grab; - touch-action: none; - user-select: none; -} - -.game-resource-focus-titlebar:active { - cursor: grabbing; } .game-resource-focus-heading { @@ -4292,6 +4335,17 @@ iframe.preview-frame { min-width: 0; } +.game-resource-focus-heading > span:last-child { + display: grid; + min-width: 0; +} + +.game-resource-focus-heading small { + color: #a27764; + font-size: 9px; + font-weight: 700; +} + .game-resource-focus-icon { display: grid; width: 36px; @@ -4317,24 +4371,20 @@ iframe.preview-frame { align-content: start; gap: 6px; min-height: 0; - padding: 14px 16px 16px; - overflow: auto; + padding: 18px 20px 24px; + overflow: hidden; overscroll-behavior: contain; scrollbar-gutter: stable; } -.game-resource-focus span, -.game-resource-focus small { - overflow-wrap: anywhere; +.game-resource-focus--document .game-resource-focus-body, +.game-resource-focus--art .game-resource-focus-body, +.game-resource-focus--audio .game-resource-focus-body { + grid-template-rows: minmax(0, 1fr) auto; } -.game-resource-focus span { - font-size: 11px; -} - -.game-resource-focus small { - color: #92776c; - font-size: 10px; +.game-resource-focus--version .game-resource-focus-body { + overflow: auto; } .game-resource-focus-close { @@ -4351,10 +4401,43 @@ iframe.preview-frame { cursor: pointer; } +.game-resource-focus-metadata { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 18px; + margin: 0 0 8px; + padding: 12px 14px; + border: 1px solid #eaded8; + border-radius: 12px; + background: #fff; +} + +.game-resource-focus-metadata > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + min-width: 0; +} + +.game-resource-focus-metadata dt, +.game-resource-focus-metadata dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 10px; +} + +.game-resource-focus-metadata dt { + color: #a08377; +} + +.game-resource-focus-metadata dd { + color: #5d4339; +} + .game-resource-image-preview { position: relative; display: grid; - height: min(420px, calc(100dvh - 360px)); + height: min(460px, calc(100dvh - 330px)); min-height: 260px; margin-bottom: 8px; overflow: hidden; @@ -4373,6 +4456,66 @@ iframe.preview-frame { place-items: center; } +.game-resource-media-preview, +.game-resource-audio-preview { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + margin-bottom: 8px; + overflow: hidden; + border: 1px solid #ead8cf; + border-radius: 12px; + background: #faf7f5; + place-items: center; +} + +.game-resource-media-preview { + background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f1ebe7 75%), + linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0; + background-size: 16px 16px; +} + +.game-resource-media-preview img, +.game-resource-media-preview video { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.game-resource-media-preview video { + width: 100%; + height: 100%; + background: #211d1b; +} + +.game-resource-audio-preview { + align-content: center; + padding: 28px; + background: linear-gradient(145deg, #fffaf6, #f5e7df); +} + +.game-resource-audio-preview audio { + width: min(620px, 100%); +} + +.game-resource-media-preview p, +.game-resource-audio-preview p { + margin: 0; + padding: 20px; + color: #92776c; + font-size: 12px; + text-align: center; +} + .game-resource-image-preview img { position: absolute; inset: 0; @@ -4391,17 +4534,19 @@ iframe.preview-frame { } .game-resource-document-body { - height: max-content; - min-height: 120px; + min-width: 0; + min-height: 0; margin-top: 6px; padding: 14px; border-radius: 10px; background: #f8f2ee; - color: #9e8579; - font-size: 11px; + color: #765c51; + font-size: 12px; line-height: 1.6; - overflow: hidden; + overflow: auto; overflow-wrap: anywhere; + overscroll-behavior: contain; + scrollbar-gutter: stable; user-select: text; } @@ -4483,6 +4628,19 @@ iframe.preview-frame { text-decoration: underline; } +.game-resource-document-link-text { + color: #8f5e49; + text-decoration: underline dotted; +} + +.game-resource-document-image-placeholder { + display: inline-block; + padding: 0.2em 0.45em; + border-radius: 5px; + background: #eaded7; + color: #806559; +} + .game-resource-document-body table { width: 100%; border-collapse: collapse; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx new file mode 100644 index 000000000..38a51a13c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -0,0 +1,676 @@ +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { + ProjectResourceCanvasPosition, + ProjectResourceCanvasSection, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, +} from './resourceCanvasLayoutModel'; +import { + type ProjectResourceGraph, + type ProjectResourceReferenceEdge, + type ProjectResourceTaskFlow, +} from './resourceDependencyGraphModel'; + +type Point = { + x: number; + y: number; +}; + +type Rect = Point & { + width: number; + height: number; +}; + +type SectionOrigins = Partial>; + +type RectLookup = { + get(resourceId: string): Rect | undefined; +}; + +export type ResourceDependencyOverlayProps = { + graph: ProjectResourceGraph; + positions: readonly ProjectResourceCanvasPosition[]; + visibleResourceIds: ReadonlySet; +}; + +export type ResourceDependencyOverlayHandle = { + updateDragPreview: (preview: Point & { resourceId: string }) => void; + clearDragPreview: () => void; +}; + +type TaskFlowPathRefs = { + sourceBranches: Map; + targetBranches: Map; + trunk: SVGPathElement | null; +}; + +type TaskFlowSectionGeometry = NonNullable< + ReturnType +> & { + section: ProjectResourceCanvasSection; +}; + +const SECTION_SELECTOR = '[data-resource-section-plane]'; +const TASK_FLOW_HUB_GAP = 20; +const CONNECTION_MAX_HANDLE = 180; +const TASK_FLOW_BRANCH_MAX_HANDLE = 96; +const SELF_REFERENCE_LOOP_WIDTH = 56; +const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; +const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ + 'document', + 'version', + 'art', + 'audio', +]; + +function pointsEqual(left: SectionOrigins, right: SectionOrigins) { + return RESOURCE_SECTIONS.every( + (section) => + left[section]?.x === right[section]?.x && + left[section]?.y === right[section]?.y, + ); +} + +function connectionPath(source: Point, target: Point) { + if (source.x === target.x && source.y === target.y) { + return `M ${source.x} ${source.y} C ${source.x + 48} ${source.y - 48}, ${ + source.x + 48 + } ${source.y + 48}, ${source.x} ${source.y + 1}`; + } + const direction = target.x >= source.x ? 1 : -1; + const bend = Math.min( + CONNECTION_MAX_HANDLE, + Math.max( + 32, + Math.abs(target.x - source.x) * 0.42 + + Math.abs(target.y - source.y) * 0.08, + ), + ); + return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${ + source.y + }, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`; +} + +function taskFlowBranchPath(source: Point, target: Point) { + const horizontalDistance = Math.abs(target.x - source.x); + if (horizontalDistance < 1) { + const direction = target.y >= source.y ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + Math.abs(target.y - source.y) * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x} ${ + source.y + direction * handle + }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + } + const direction = target.x >= source.x ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + horizontalDistance * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ + source.y + }, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`; +} + +function rectCenter(rect: Rect): Point { + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; +} + +function average(values: readonly number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function rectAnchor(rect: Rect, direction: 1 | -1): Point { + return { + x: direction === 1 ? rect.x + rect.width : rect.x, + y: rect.y + rect.height / 2, + }; +} + +function referenceGeometry( + edge: ProjectResourceReferenceEdge, + rectByResourceId: RectLookup, +) { + const sourceRect = rectByResourceId.get(edge.sourceResourceId); + const targetRect = rectByResourceId.get(edge.targetResourceId); + if (!sourceRect || !targetRect) { + return null; + } + if (edge.sourceResourceId === edge.targetResourceId) { + const anchorX = sourceRect.x + sourceRect.width; + const centerY = sourceRect.y + sourceRect.height / 2; + const sourceY = centerY + SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const targetY = centerY - SELF_REFERENCE_LOOP_ANCHOR_OFFSET; + const loopX = anchorX + SELF_REFERENCE_LOOP_WIDTH; + return { + path: `M ${anchorX} ${sourceY} C ${loopX} ${sourceY}, ${loopX} ${targetY}, ${anchorX} ${targetY}`, + selfLoop: true, + }; + } + const sourceCenter = rectCenter(sourceRect); + const targetCenter = rectCenter(targetRect); + const direction: 1 | -1 = targetCenter.x >= sourceCenter.x ? 1 : -1; + const source = rectAnchor(sourceRect, direction); + const target = rectAnchor(targetRect, direction === 1 ? -1 : 1); + return { + path: connectionPath(source, target), + selfLoop: false, + }; +} + +function taskFlowGeometry( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, +) { + const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + const targetRects = flow.targetResourceIds.flatMap((resourceId) => { + const rect = rectByResourceId.get(resourceId); + return rect ? [{ resourceId, rect }] : []; + }); + if (sourceRects.length === 0 || targetRects.length === 0) { + return null; + } + const sourceCenterX = average( + sourceRects.map(({ rect }) => rectCenter(rect).x), + ); + const targetCenterX = average( + targetRects.map(({ rect }) => rectCenter(rect).x), + ); + const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1; + const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction), + })); + const targetAnchors = targetRects.map(({ resourceId, rect }) => ({ + resourceId, + point: rectAnchor(rect, direction === 1 ? -1 : 1), + })); + const sourceHub: Point = { + x: + (direction === 1 + ? Math.max(...sourceAnchors.map(({ point }) => point.x)) + : Math.min(...sourceAnchors.map(({ point }) => point.x))) + + direction * TASK_FLOW_HUB_GAP, + y: average(sourceAnchors.map(({ point }) => point.y)), + }; + const targetHub: Point = { + x: + (direction === 1 + ? Math.min(...targetAnchors.map(({ point }) => point.x)) + : Math.max(...targetAnchors.map(({ point }) => point.x))) - + direction * TASK_FLOW_HUB_GAP, + y: average(targetAnchors.map(({ point }) => point.y)), + }; + return { sourceAnchors, targetAnchors, sourceHub, targetHub }; +} + +function taskFlowSectionGeometries( + flow: ProjectResourceTaskFlow, + rectByResourceId: RectLookup, + sectionByResourceId: ReadonlyMap, +): TaskFlowSectionGeometry[] { + return RESOURCE_SECTIONS.flatMap((section) => { + const geometry = taskFlowGeometry( + { + ...flow, + sourceResourceIds: flow.sourceResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + targetResourceIds: flow.targetResourceIds.filter( + (resourceId) => sectionByResourceId.get(resourceId) === section, + ), + }, + rectByResourceId, + ); + return geometry ? [{ ...geometry, section }] : []; + }); +} + +function taskFlowRenderKey( + flowId: string, + section: ProjectResourceCanvasSection, +) { + return `${flowId}\n${section}`; +} + +export const ResourceDependencyOverlay = forwardRef< + ResourceDependencyOverlayHandle, + ResourceDependencyOverlayProps +>(function ResourceDependencyOverlay( + { graph, positions, visibleResourceIds }, + ref, +) { + const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); + const overlayRef = useRef(null); + const referencePathRefs = useRef(new Map()); + const taskFlowPathRefs = useRef(new Map()); + const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>( + null, + ); + const graphRef = useRef(graph); + const positionByResourceIdRef = useRef( + new Map(positions.map((position) => [position.resourceId, position])), + ); + const rectByResourceIdRef = useRef>(new Map()); + const [sectionOrigins, setSectionOrigins] = useState({}); + + useLayoutEffect(() => { + const canvas = overlayRef.current?.parentElement; + if (!canvas) { + return undefined; + } + let frameId: number | null = null; + const measure = () => { + frameId = null; + const canvasRect = canvas.getBoundingClientRect(); + const next: SectionOrigins = {}; + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => { + const section = plane.dataset.resourceSectionPlane as + | ProjectResourceCanvasSection + | undefined; + if (!section) { + return; + } + const planeRect = plane.getBoundingClientRect(); + next[section] = { + x: planeRect.left - canvasRect.left, + y: planeRect.top - canvasRect.top, + }; + }); + setSectionOrigins((current) => + pointsEqual(current, next) ? current : next, + ); + }; + const scheduleMeasure = () => { + if (frameId !== null) { + return; + } + frameId = window.requestAnimationFrame(measure); + }; + measure(); + const ResizeObserverClass = window.ResizeObserver; + const observer = ResizeObserverClass + ? new ResizeObserverClass(scheduleMeasure) + : null; + observer?.observe(canvas); + canvas + .querySelectorAll(SECTION_SELECTOR) + .forEach((plane) => observer?.observe(plane)); + window.addEventListener('resize', scheduleMeasure); + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + observer?.disconnect(); + window.removeEventListener('resize', scheduleMeasure); + }; + }, []); + + const rectByResourceId = useMemo(() => { + const result = new Map(); + for (const position of positions) { + if ( + !graph.resourceIds.has(position.resourceId) || + !visibleResourceIds.has(position.resourceId) + ) { + continue; + } + const origin = sectionOrigins[position.section]; + if (!origin) { + continue; + } + result.set(position.resourceId, { + x: origin.x + position.x, + y: origin.y + position.y, + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, + }); + } + return result; + }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + const sectionByResourceId = useMemo( + () => + new Map( + positions.map((position) => [position.resourceId, position.section]), + ), + [positions], + ); + graphRef.current = graph; + positionByResourceIdRef.current = new Map( + positions.map((position) => [position.resourceId, position]), + ); + rectByResourceIdRef.current = rectByResourceId; + + const taskFlowRenderEntries = useMemo( + () => + graph.taskFlows.flatMap((flow) => + taskFlowSectionGeometries( + flow, + rectByResourceId, + sectionByResourceId, + ).map((geometry) => ({ + flow, + geometry, + renderKey: taskFlowRenderKey(flow.id, geometry.section), + })), + ), + [graph.taskFlows, rectByResourceId, sectionByResourceId], + ); + + const renderTaskFlows = useMemo( + () => + taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => { + const className = `game-resource-dependency-edge game-resource-dependency-edge--task${ + flow.cyclic ? ' is-cyclic' : '' + }`; + return ( + + {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ + flow.cyclic ? '(检测到依赖环)' : '' + }`} + {geometry.sourceAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.sourceBranches.set(resourceId, node); + } else { + refs.sourceBranches.delete(resourceId); + } + }} + key={`source:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="source" + data-resource-id={resourceId} + d={taskFlowBranchPath(point, geometry.sourceHub)} + /> + ))} + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + refs.trunk = node; + }} + className="game-resource-dependency-trunk" + d={connectionPath(geometry.sourceHub, geometry.targetHub)} + /> + {geometry.targetAnchors.map(({ resourceId, point }) => ( + { + let refs = taskFlowPathRefs.current.get(renderKey); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(renderKey, refs); + } + if (node) { + refs.targetBranches.set(resourceId, node); + } else { + refs.targetBranches.delete(resourceId); + } + }} + key={`target:${resourceId}`} + className="game-resource-dependency-branch" + data-branch-side="target" + data-resource-id={resourceId} + d={taskFlowBranchPath(geometry.targetHub, point)} + markerEnd={`url(#${markerPrefix}-task-flow-arrow)`} + /> + ))} + + ); + }), + [markerPrefix, taskFlowRenderEntries], + ); + + const renderReferenceEdges = useMemo( + () => + graph.referenceEdges.map((edge) => { + const geometry = referenceGeometry(edge, rectByResourceId); + if (!geometry) { + return null; + } + const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${ + edge.cyclic ? ' is-cyclic' : '' + }`; + return ( + { + if (node) { + referencePathRefs.current.set(edge.id, node); + } else { + referencePathRefs.current.delete(edge.id); + } + }} + key={edge.id} + className={className} + data-edge-kind="asset-reference" + data-edge-id={edge.id} + data-source-resource-id={edge.sourceResourceId} + data-target-resource-id={edge.targetResourceId} + data-cyclic={edge.cyclic || undefined} + data-self-loop={geometry.selfLoop || undefined} + d={geometry.path} + markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`} + > + {`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`} + + ); + }), + [graph.referenceEdges, markerPrefix, rectByResourceId], + ); + + useLayoutEffect(() => { + const activeRenderKeys = new Set( + taskFlowRenderEntries.map((entry) => entry.renderKey), + ); + for (const renderKey of taskFlowPathRefs.current.keys()) { + if (!activeRenderKeys.has(renderKey)) { + taskFlowPathRefs.current.delete(renderKey); + } + } + }, [taskFlowRenderEntries]); + + const updateAffectedGeometry = useCallback( + ( + affectedResourceIds: ReadonlySet, + dragPreview: (Point & { resourceId: string }) | null, + ) => { + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; + const dragBasePosition = dragPreview + ? positionByResourceIdRef.current.get(dragPreview.resourceId) + : undefined; + const rectLookup = { + get(resourceId: string) { + const rect = currentRects.get(resourceId); + if (!rect) { + return undefined; + } + return dragPreview?.resourceId === resourceId + ? { + ...rect, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + } + : rect; + }, + }; + const affectedEdgeIds = new Set(); + for (const resourceId of affectedResourceIds) { + const index = currentGraph.connectionIndex.get(resourceId); + index?.referenceEdgeIds.forEach((edgeId) => + affectedEdgeIds.add(edgeId), + ); + index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); + } + for (const edgeId of affectedEdgeIds) { + const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); + if (referenceEdge) { + const geometry = referenceGeometry(referenceEdge, rectLookup); + const path = referencePathRefs.current.get(edgeId); + if (geometry && path) { + path.setAttribute('d', geometry.path); + } + continue; + } + const flow = currentGraph.taskFlowById.get(edgeId); + if (!flow) { + continue; + } + const sectionByResourceId = new Map( + Array.from( + positionByResourceIdRef.current.values(), + (position) => [position.resourceId, position.section] as const, + ), + ); + for (const geometry of taskFlowSectionGeometries( + flow, + rectLookup, + sectionByResourceId, + )) { + const paths = taskFlowPathRefs.current.get( + taskFlowRenderKey(flow.id, geometry.section), + ); + if (!paths) { + continue; + } + geometry.sourceAnchors.forEach(({ resourceId, point }) => { + paths.sourceBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(point, geometry.sourceHub), + ); + }); + paths.trunk?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); + geometry.targetAnchors.forEach(({ resourceId, point }) => { + paths.targetBranches + .get(resourceId) + ?.setAttribute( + 'd', + taskFlowBranchPath(geometry.targetHub, point), + ); + }); + } + } + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + const affectedResourceIds = new Set(); + if (activeDragPreviewRef.current) { + affectedResourceIds.add(activeDragPreviewRef.current.resourceId); + } + affectedResourceIds.add(preview.resourceId); + activeDragPreviewRef.current = preview; + updateAffectedGeometry(affectedResourceIds, preview); + }, + clearDragPreview() { + const active = activeDragPreviewRef.current; + activeDragPreviewRef.current = null; + if (active) { + updateAffectedGeometry(new Set([active.resourceId]), null); + } + }, + }), + [updateAffectedGeometry], + ); + + return ( + + ); +}); 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 dd5e99492..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 @@ -18,10 +18,11 @@ import { } from 'lucide-react'; import { type CSSProperties, - type PointerEvent as ReactPointerEvent, + memo, type ReactNode, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -34,64 +35,55 @@ import type { GameCreationAppAgentGroup, GameCreationAppManifest, GameCreationAppPreviewState, - GameCreationAppTaskState, ProjectResourceCanvasLayoutMode, - ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; import { - RESOURCE_CANVAS_DRAG_THRESHOLD, - resourceCanvasSectionExtent, -} from './resourceCanvasLayoutModel'; + EMPTY_PROJECT_RESOURCE_GRAPH, + normalizeProjectResourceGraph, + type ProjectResourceGraph, + type ProjectResourceGraphNodeInput, + type ProjectResourceGraphReadModel, +} from './resourceDependencyGraphModel'; +import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + type ProjectAgentResultSummary, + type ProjectAttachmentResult, + type ProjectResource, + type ProjectResourceCategory, + projectResourcesFromReadModels, +} from './resourceProjectionModel'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; -type AttachmentResult = { - fileName: string; - mediaType: string; - localPath?: string; - status: 'imported' | 'failed'; - error?: string; -}; +export type { + ProjectAgentResultSummary, + ProjectVersionResourceSummary, +} from './resourceProjectionModel'; -type ResourceCategory = ProjectResourceCanvasSection; +type ResourceCategory = ProjectResourceCategory; type ResourceSortMode = ProjectResourceCanvasLayoutMode; type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; -type Point = { - x: number; - y: number; -}; - -type ResourceCardDrag = { - pointerId: number; - resourceId: string; - section: ResourceCategory; - startClientX: number; - startClientY: number; - startX: number; - startY: number; - moved: boolean; -}; - -type ProjectResource = { - id: string; - category: ResourceCategory; - subtype: string; - label: string; +type LocalProjectImagePreview = { path: string; mediaType: string; - sourceLabel: string; - taskTitle: string | null; - dependencies: string[]; - dependencyDepth: number; - content?: string; + byteLen: number; + dataUrl: string; }; -type LocalProjectImagePreview = { +type LocalProjectTextPreview = { + path: string; + mediaType: string; + byteLen: number; + content: string; +}; + +type LocalProjectMediaPreview = { path: string; mediaType: string; byteLen: number; @@ -108,14 +100,25 @@ type ImagePreviewState = } | { status: 'failed'; resourceId: string; error: string }; -export type ProjectAgentResultSummary = { - agentId: string; - runId: string; - label: string; - title: string; - content: string; - updatedAt: number; -}; +type TextPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectTextPreview; + } + | { status: 'failed'; resourceId: string; error: string }; + +type MediaPreviewState = + | { status: 'idle'; resourceId: null } + | { status: 'loading'; resourceId: string } + | { + status: 'loaded'; + resourceId: string; + preview: LocalProjectMediaPreview; + } + | { status: 'failed'; resourceId: string; error: string }; export type ProjectAgentRuntimeSummary = { group: GameCreationAppAgentGroup; @@ -131,6 +134,7 @@ export type ProjectAgentRuntimeSummary = { const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = []; const emptyProjectAgentResults: ProjectAgentResultSummary[] = []; +const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64; type AgentSummary = ProjectAgentRuntimeSummary; @@ -138,7 +142,7 @@ export type ProjectDevelopmentViewProps = { projectName: string; projectPath: string; manifest: GameCreationAppManifest; - attachments: AttachmentResult[]; + attachments: ProjectAttachmentResult[]; recentRunStatus: string | null; recentRunStopReason: string | null; preview?: GameCreationAppPreviewState | null; @@ -196,36 +200,6 @@ const approvalOptions: Array<{ }, ]; -function fileName(path: string) { - return path.split(/[\\/]/).filter(Boolean).pop() || path; -} - -function categoryFromResource(path: string, mediaType: string) { - const normalizedPath = path.toLowerCase(); - const normalizedMediaType = mediaType.toLowerCase(); - if ( - normalizedMediaType.startsWith('audio/') || - /\.(mp3|wav|ogg|m4a|aac|flac)$/u.test(normalizedPath) - ) { - return 'audio' as const; - } - if ( - normalizedMediaType.startsWith('image/') || - normalizedMediaType.startsWith('video/') || - /\.(png|jpe?g|webp|gif|svg|mp4|webm)$/u.test(normalizedPath) - ) { - return 'art' as const; - } - if ( - normalizedMediaType.includes('json') || - normalizedMediaType.startsWith('text/') || - /\.(md|txt|json|ya?ml|toml)$/u.test(normalizedPath) - ) { - return 'document' as const; - } - return 'version' as const; -} - function isRasterImageResource(resource: ProjectResource) { const mediaType = resource.mediaType.toLowerCase(); return ( @@ -235,6 +209,77 @@ function isRasterImageResource(resource: ProjectResource) { ); } +function isExtendedArtMediaResource(resource: ProjectResource) { + const mediaType = resource.mediaType.toLowerCase(); + return ( + resource.category === 'art' && + (mediaType === 'image/svg+xml' || + mediaType.startsWith('video/') || + /\.(gif|svg|avif|bmp|mp4|webm|mov)$/iu.test(resource.path)) + ); +} + +function mediaPreviewErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('项目权限策略要求用户确认')) { + return '当前项目策略要求先确认读取资源'; + } + if (message.includes('项目权限策略拒绝执行')) { + return '当前项目策略不允许读取资源'; + } + if (message.includes('不能超过')) { + return message; + } + if (message.includes('UTF-8') || message.includes('只支持')) { + return message; + } + if (message.includes('脚本或外部资源引用')) { + return message; + } + if (message.includes('发生漂移') || message.includes('发生替换')) { + return '资源读取期间发生变化,请关闭后重试'; + } + return '资源暂时无法读取,请关闭后重试'; +} + +function formatMediaDuration(duration: number | null) { + if (duration === null || !Number.isFinite(duration) || duration < 0) { + return '载入后显示'; + } + const totalSeconds = Math.floor(duration); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function formatVersionCreatedAt(createdAt: number) { + const value = new Date(createdAt); + return Number.isFinite(value.getTime()) + ? value.toLocaleString('zh-CN') + : String(createdAt); +} + +function SafeProjectMarkdown({ content }: { content: string }) { + return ( + ( + {children} + ), + img: ({ alt }) => ( + + {alt ? `图片:${alt}` : '文档图片已省略'} + + ), + }} + > + {content} + + ); +} + function imagePreviewErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : String(error); if (message.includes('项目权限策略要求用户确认')) { @@ -264,135 +309,6 @@ function imagePreviewErrorMessage(error: unknown) { return '图片暂时无法读取,请关闭后重试'; } -function taskDependencyDepth( - task: GameCreationAppTaskState, - taskById: Map, - seen = new Set(), -): number { - if (seen.has(task.id) || task.dependencies.length === 0) { - return 0; - } - const nextSeen = new Set(seen).add(task.id); - return ( - 1 + - Math.max( - 0, - ...task.dependencies.map((dependency) => { - const dependencyTask = taskById.get(dependency); - return dependencyTask - ? taskDependencyDepth(dependencyTask, taskById, nextSeen) - : 0; - }), - ) - ); -} - -function resourcesFromProject( - manifest: GameCreationAppManifest, - attachments: AttachmentResult[], - agentResults: ProjectAgentResultSummary[], -) { - const taskById = new Map(manifest.tasks.map((task) => [task.id, task])); - const resources: ProjectResource[] = []; - - for (const task of manifest.tasks) { - if (task.status !== 'completed') { - continue; - } - for (const path of task.artifacts) { - const category = categoryFromResource(path, ''); - resources.push({ - id: `task:${task.id}:${path}`, - category, - subtype: 'task-artifact', - label: fileName(path), - path, - mediaType: category === 'document' ? '项目文档' : '项目产物', - sourceLabel: '任务产物', - taskTitle: task.title, - dependencies: task.dependencies, - dependencyDepth: taskDependencyDepth(task, taskById), - }); - } - } - - for (const asset of manifest.assets) { - const task = asset.source.taskId - ? taskById.get(asset.source.taskId) - : undefined; - const isPendingUiPrototype = - asset.kind === 'ui-prototype' && - taskById.get('design-foundation')?.status !== 'completed'; - resources.push({ - id: `asset:${asset.id}`, - category: categoryFromResource(asset.localPath, asset.mediaType), - subtype: asset.kind, - label: `${fileName(asset.localPath)}${ - isPendingUiPrototype ? '(待视觉验收)' : '' - }`, - path: asset.localPath, - mediaType: asset.mediaType, - sourceLabel: - isPendingUiPrototype && asset.source.kind === 'canvas' - ? '画板 · 候选界面图' - : asset.source.kind === 'canvas' - ? '画板' - : asset.source.kind === 'generated' - ? 'Agent 生成' - : '用户上传', - taskTitle: task?.title ?? null, - dependencies: task?.dependencies ?? [], - dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0, - }); - } - - for (const attachment of attachments) { - if (attachment.status !== 'imported' || !attachment.localPath) { - continue; - } - resources.push({ - id: `attachment:${attachment.localPath}`, - category: categoryFromResource( - attachment.localPath, - attachment.mediaType, - ), - subtype: 'attachment', - label: attachment.fileName, - path: attachment.localPath, - mediaType: attachment.mediaType || '未知媒体类型', - sourceLabel: '用户上传', - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - }); - } - - for (const result of agentResults) { - resources.push({ - id: `agent-result:${result.agentId}:${result.runId}`, - category: 'document', - subtype: 'agent-result', - label: result.title, - path: `专业 Agent 文本回执 · ${result.label}`, - mediaType: 'Agent 历史文本回执', - sourceLabel: `历史成果 · ${result.label}`, - taskTitle: null, - dependencies: [], - dependencyDepth: 0, - content: result.content, - }); - } - - const uniqueByPath = new Map(); - for (const resource of resources) { - const existing = uniqueByPath.get(resource.path); - if (!existing || resource.id.startsWith('asset:')) { - uniqueByPath.set(resource.path, resource); - } - } - return Array.from(uniqueByPath.values()); -} - function summarizeAgent( manifest: GameCreationAppManifest, group: AgentSummary['group'], @@ -436,60 +352,54 @@ function summarizeAgent( }; } -function ResourceCard({ +const ResourceCard = memo(function ResourceCard({ resource, selected, - dragging, + relationState, x, y, onSelect, - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, }: { resource: ProjectResource; selected: boolean; - dragging: boolean; + relationState: 'version-binding' | null; x: number; y: number; - onSelect: () => void; - onPointerDown: (event: ReactPointerEvent) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; + onSelect: (resourceId: string) => void; }) { const Icon = categoryIcons[resource.category]; return ( ); -} +}); export default function ProjectDevelopmentView({ projectName, @@ -507,35 +417,38 @@ export default function ProjectDevelopmentView({ const [selectedResourceId, setSelectedResourceId] = useState( null, ); + const [focusedResourceId, setFocusedResourceId] = useState( + null, + ); const [approvalMode, setApprovalMode] = useState('strict'); const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); const [approvalNotice, setApprovalNotice] = useState(''); const [showAllAgentGroups, setShowAllAgentGroups] = useState(false); const [runPlaying, setRunPlaying] = useState(false); const [activeSlice, setActiveSlice] = useState(0); - const [draggedResourceId, setDraggedResourceId] = useState( - null, - ); - const [resourceDragPreview, setResourceDragPreview] = useState< - (Point & { resourceId: string }) | null - >(null); - const [resourceDialogPosition, setResourceDialogPosition] = - useState(null); const [imagePreview, setImagePreview] = useState({ status: 'idle', resourceId: null, }); - const workbenchRef = useRef(null); - const stageRef = useRef(null); - const dockRef = useRef(null); - const resourceDialogRef = useRef(null); - const resourceCardDragRef = useRef(null); - const suppressResourceClickRef = useRef(null); - const resourceDialogDragRef = useRef<{ - pointerId: number; - offsetX: number; - offsetY: number; - } | null>(null); + const [textPreview, setTextPreview] = useState({ + status: 'idle', + resourceId: null, + }); + const [mediaPreview, setMediaPreview] = useState({ + status: 'idle', + resourceId: null, + }); + 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(); const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); @@ -544,40 +457,295 @@ export default function ProjectDevelopmentView({ manifest.tasks.some( (task) => task.id === 'code-prototype' && task.status === 'completed', ); - const resources = useMemo( - () => resourcesFromProject(manifest, attachments, agentResults), + const projectedResources = useMemo( + () => projectResourcesFromReadModels(manifest, attachments, agentResults), [agentResults, attachments, manifest], ); + const resourceGraphInputs = useMemo( + () => + projectedResources.map((resource) => ({ + resourceId: resource.id, + manifestAssetId: resource.manifestAssetId, + producerTaskId: resource.producerTaskId, + })), + [projectedResources], + ); + const resourceGraphScopeKey = useMemo( + () => + JSON.stringify([projectPath, manifest.projectId, resourceGraphInputs]), + [manifest.projectId, projectPath, resourceGraphInputs], + ); + const [resourceGraphState, setResourceGraphState] = useState<{ + scopeKey: string; + status: 'idle' | 'loading' | 'ready' | 'failed'; + graph: ProjectResourceGraph; + }>({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + + useEffect(() => { + let cancelled = false; + if (sortMode !== 'dependency') { + setResourceGraphState({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + return () => { + cancelled = true; + }; + } + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'loading', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + void invoke( + 'read_local_project_resource_graph', + { + projectPath, + expectedProjectId: manifest.projectId, + resources: resourceGraphInputs, + }, + ) + .then((readModel) => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'ready', + graph: normalizeProjectResourceGraph(readModel), + }); + } + }) + .catch(() => { + if (!cancelled) { + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'failed', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + manifest.projectId, + projectPath, + resourceGraphInputs, + resourceGraphScopeKey, + sortMode, + ]); + + const resourceGraphScopeMatches = + resourceGraphState.scopeKey === resourceGraphScopeKey; + const resourceGraphReady = + resourceGraphScopeMatches && resourceGraphState.status === 'ready'; + const resourceGraph = resourceGraphReady + ? resourceGraphState.graph + : EMPTY_PROJECT_RESOURCE_GRAPH; + const resourceGraphInitializationReady = + sortMode !== 'dependency' || + (resourceGraphScopeMatches && + (resourceGraphState.status === 'ready' || + resourceGraphState.status === 'failed')); + const manifestTaskById = useMemo( + () => new Map(manifest.tasks.map((task) => [task.id, task])), + [manifest.tasks], + ); + const resources = useMemo( + () => + projectedResources.map((resource) => { + const producerTaskId = + resourceGraph.producerTaskIdByResourceId.get(resource.id) ?? + resource.producerTaskId; + const producerTask = producerTaskId + ? manifestTaskById.get(producerTaskId) + : undefined; + return { + ...resource, + taskTitle: producerTask?.title ?? resource.taskTitle, + producerTaskId, + dependencies: producerTask?.dependencies ?? resource.dependencies, + dependencyDepth: + resourceGraph.dependencyDepthByResourceId.get(resource.id) ?? 0, + }; + }), + [manifestTaskById, projectedResources, resourceGraph], + ); const { layout: resourceLayout, notice: resourceLayoutNotice, saving: resourceLayoutSaving, - commitPosition: commitResourcePosition, } = useProjectResourceCanvasLayout({ projectPath, projectId: manifest.projectId, mode: sortMode, resources, + initializationReady: resourceGraphInitializationReady, + rederiveAutomaticPositions: sortMode === 'dependency' && resourceGraphReady, }); - const resourcePositionById = new Map( - resourceLayout.positions.map((position) => [position.resourceId, position]), + const resourcePositionById = useMemo( + () => + new Map( + resourceLayout.positions.map((position) => [ + position.resourceId, + position, + ]), + ), + [resourceLayout.positions], ); + const selectedVersionBindingResourceIds = useMemo(() => { + const selectedVersion = resources.find( + (resource) => resource.id === selectedResourceId, + )?.version; + if (!selectedVersion) { + return new Set(); + } + const boundManifestAssetIds = new Set( + selectedVersion.resourceBindings.map((binding) => binding.resourceId), + ); + return new Set( + resources + .filter( + (resource) => + resource.manifestAssetId !== null && + boundManifestAssetIds.has(resource.manifestAssetId), + ) + .map((resource) => resource.id), + ); + }, [resources, selectedResourceId]); const normalizedSearch = searchText.trim().toLowerCase(); - const visibleResources = resources.filter((resource) => - normalizedSearch - ? [ - resource.label, - resource.path, - resource.mediaType, - resource.taskTitle ?? '', - ].some((value) => value.toLowerCase().includes(normalizedSearch)) - : true, + const visibleResources = useMemo( + () => + resources.filter((resource) => + normalizedSearch + ? [ + resource.label, + resource.path, + resource.mediaType, + resource.taskTitle ?? '', + ].some((value) => value.toLowerCase().includes(normalizedSearch)) + : true, + ), + [normalizedSearch, resources], + ); + const visibleResourceIds = useMemo( + () => new Set(visibleResources.map((resource) => resource.id)), + [visibleResources], + ); + const visibleResourcesByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + visibleResources.filter((resource) => resource.category === category), + ]), + ), + [visibleResources], + ); + const dependencyRelationshipDescriptions = useMemo(() => { + if (sortMode !== 'dependency') { + return []; + } + const labelByResourceId = new Map( + resources.map((resource) => [resource.id, resource.label]), + ); + const categoryByResourceId = new Map( + resources.map((resource) => [resource.id, resource.category]), + ); + const descriptions = new Set( + resourceGraph.referenceEdges.flatMap((edge) => + visibleResourceIds.has(edge.sourceResourceId) && + visibleResourceIds.has(edge.targetResourceId) + ? [ + `${labelByResourceId.get(edge.targetResourceId) ?? edge.targetResourceId} 引用 ${ + labelByResourceId.get(edge.sourceResourceId) ?? + edge.sourceResourceId + }${edge.cyclic ? ',检测到依赖环' : ''}`, + ] + : [], + ), + ); + for (const flow of resourceGraph.taskFlows) { + for (const category of categoryOrder) { + const sourceLabels = flow.sourceResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + const targetLabels = flow.targetResourceIds + .filter( + (resourceId) => + visibleResourceIds.has(resourceId) && + categoryByResourceId.get(resourceId) === category, + ) + .map((resourceId) => labelByResourceId.get(resourceId) ?? resourceId); + if (sourceLabels.length > 0 && targetLabels.length > 0) { + descriptions.add( + `任务 ${flow.sourceTaskId} 的资源 ${sourceLabels.join('、')} 流向任务 ${ + flow.targetTaskId + } 的资源 ${targetLabels.join('、')}${flow.cyclic ? ',检测到依赖环' : ''}`, + ); + } + } + } + return Array.from(descriptions); + }, [resourceGraph, resources, sortMode, visibleResourceIds]); + const resourcePositionsByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceLayout.positions.filter( + (position) => position.section === category, + ), + ]), + ), + [resourceLayout.positions], + ); + const resourceBaseExtentByCategory = useMemo( + () => + new Map( + categoryOrder.map((category) => [ + category, + resourceCanvasSectionExtent( + resourcePositionsByCategory.get(category) ?? [], + ), + ]), + ), + [resourcePositionsByCategory], ); const selectedResource = resources.find((resource) => resource.id === selectedResourceId) ?? null; - const selectedResourceIsImage = Boolean( - selectedResource && isRasterImageResource(selectedResource), + 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), ); + const focusedResourceIsExtendedArtMedia = Boolean( + focusedResource && isExtendedArtMediaResource(focusedResource), + ); + const focusedResourceIsAudio = focusedResource?.category === 'audio'; const hasRegisteredArtImageAssets = manifest.assets.some( (asset) => asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'), @@ -613,35 +781,68 @@ export default function ProjectDevelopmentView({ const currentApprovalLabel = approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const FocusIcon = focusedResource + ? categoryIcons[focusedResource.category] + : FileText; + const focusedPreviewMediaType = + focusedResource && + mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id + ? mediaPreview.preview.mediaType + : focusedResource && + imagePreview.status === 'loaded' && + imagePreview.resourceId === focusedResource.id + ? imagePreview.preview.mediaType + : focusedResource && + textPreview.status === 'loaded' && + textPreview.resourceId === focusedResource.id + ? textPreview.preview.mediaType + : focusedResource?.mediaType; 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); - resourceCardDragRef.current = null; - setDraggedResourceId(null); - setResourceDragPreview(null); + setFocusedResourceId(null); + resourceListScrollRef.current = { left: 0, top: 0 }; + restoreResourceListScrollRef.current = false; }, [projectPath]); useEffect(() => { - if (!selectedResourceId) { + if (!focusedResourceId) { return undefined; } function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') { - setSelectedResourceId(null); + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); } } window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [selectedResourceId]); + }, [focusedResourceId]); useEffect(() => { - if (!selectedResource || !selectedResourceIsImage) { + if ( + !focusedResourceId || + !focusedResourcePath || + !focusedResourceIsImage + ) { setImagePreview({ status: 'idle', resourceId: null }); return undefined; } @@ -649,23 +850,27 @@ export default function ProjectDevelopmentView({ if (!invoke) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResourceId, error: '图片预览需要在客户端内打开', }); return undefined; } let cancelled = false; - setImagePreview({ status: 'loading', resourceId: selectedResource.id }); + setImagePreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); void invoke('read_local_project_image_preview', { projectPath, - relativePath: selectedResource.path, + relativePath: focusedResourcePath, }) .then((preview) => { if (!cancelled) { setImagePreview({ status: 'loaded', - resourceId: selectedResource.id, + resourceId: focusedResourceId, preview, }); } @@ -674,7 +879,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setImagePreview({ status: 'failed', - resourceId: selectedResource.id, + resourceId: focusedResourceId, error: imagePreviewErrorMessage(error), }); } @@ -682,212 +887,244 @@ export default function ProjectDevelopmentView({ return () => { cancelled = true; }; - }, [projectPath, selectedResource, selectedResourceIsImage]); - - const clampResourceDialogPosition = useCallback((x: number, y: number) => { - const dialog = resourceDialogRef.current; - const workbench = workbenchRef.current; - const dock = dockRef.current; - if (!dialog) { - return { x, y }; - } - - const dialogRect = dialog.getBoundingClientRect(); - const workbenchRect = workbench?.getBoundingClientRect(); - const dockRect = dock?.getBoundingClientRect(); - const viewportWidth = document.documentElement.clientWidth; - const viewportHeight = document.documentElement.clientHeight; - const minX = Math.max(12, (workbenchRect?.left ?? 0) + 12); - const maxRight = Math.min( - viewportWidth - 12, - (workbenchRect?.right ?? viewportWidth) - 12, - ); - const minY = Math.max(12, (workbenchRect?.top ?? 0) + 12); - const maxBottom = Math.min( - viewportHeight - 12, - dockRect ? dockRect.top - 12 : viewportHeight - 12, - ); - const maxX = Math.max(minX, maxRight - dialogRect.width); - const maxY = Math.max(minY, maxBottom - dialogRect.height); - - return { - x: Math.min(Math.max(x, minX), maxX), - y: Math.min(Math.max(y, minY), maxY), - }; - }, []); - - useLayoutEffect(() => { - if (!selectedResource) { - setResourceDialogPosition(null); - return; - } - const dialog = resourceDialogRef.current; - const stage = stageRef.current; - if (!dialog || !stage) { - return; - } - const dialogRect = dialog.getBoundingClientRect(); - const stageRect = stage.getBoundingClientRect(); - setResourceDialogPosition( - clampResourceDialogPosition( - stageRect.left + (stageRect.width - dialogRect.width) / 2, - stageRect.top + (stageRect.height - dialogRect.height) / 2, - ), - ); - dialog.focus({ preventScroll: true }); - }, [clampResourceDialogPosition, selectedResource]); + }, [ + focusedResourceId, + focusedResourceIsImage, + focusedResourcePath, + projectPath, + ]); useEffect(() => { - if (!selectedResource) { + if ( + !focusedResourceId || + !focusedResourcePath || + focusedResourceCategory !== 'document' + ) { + setTextPreview({ status: 'idle', resourceId: null }); return undefined; } - function clampOnResize() { - setResourceDialogPosition((current) => - current ? clampResourceDialogPosition(current.x, current.y) : current, - ); + if (focusedResourceContent !== undefined) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview: { + path: focusedResourcePath, + mediaType: focusedResourceMediaType ?? 'text/plain', + byteLen: new TextEncoder().encode(focusedResourceContent).byteLength, + content: focusedResourceContent, + }, + }); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setTextPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: '文档预览需要在客户端内打开', + }); + return undefined; } - window.addEventListener('resize', clampOnResize); - return () => window.removeEventListener('resize', clampOnResize); - }, [clampResourceDialogPosition, selectedResource]); - function handleResourceCardPointerDown( - event: ReactPointerEvent, - resource: ProjectResource, - ) { - if (event.button !== 0) { - return; - } - const position = resourcePositionById.get(resource.id); - if (!position || position.section !== resource.category) { - return; - } - resourceCardDragRef.current = { - pointerId: event.pointerId, - resourceId: resource.id, - section: resource.category, - startClientX: event.clientX, - startClientY: event.clientY, - startX: position.x, - startY: position.y, - moved: false, - }; - event.currentTarget.setPointerCapture?.(event.pointerId); - } - - function handleResourceCardPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startClientX; - const deltaY = event.clientY - drag.startClientY; - if ( - !drag.moved && - Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD - ) { - return; - } - drag.moved = true; - setDraggedResourceId(drag.resourceId); - setResourceDragPreview({ - resourceId: drag.resourceId, - x: Math.max(0, drag.startX + deltaX), - y: Math.max(0, drag.startY + deltaY), - }); - event.preventDefault(); - } - - function handleResourceCardPointerEnd( - event: ReactPointerEvent, - cancelled: boolean, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - resourceCardDragRef.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - } - if (drag.moved && !cancelled) { - suppressResourceClickRef.current = drag.resourceId; - commitResourcePosition( - drag.resourceId, - drag.section, - Math.max(0, drag.startX + event.clientX - drag.startClientX), - Math.max(0, drag.startY + event.clientY - drag.startClientY), - ); - } - setDraggedResourceId(null); - setResourceDragPreview(null); - } - - function handleResourceDialogPointerDown( - event: ReactPointerEvent, - ) { - if (event.button !== 0 || (event.target as HTMLElement).closest('button')) { - return; - } - const dialogRect = resourceDialogRef.current?.getBoundingClientRect(); - if (!dialogRect) { - return; - } - resourceDialogDragRef.current = { - pointerId: event.pointerId, - offsetX: event.clientX - dialogRect.left, - offsetY: event.clientY - dialogRect.top, - }; - event.currentTarget.setPointerCapture(event.pointerId); - event.preventDefault(); - } - - function handleResourceDialogPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceDialogDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - setResourceDialogPosition( - clampResourceDialogPosition( - event.clientX - drag.offsetX, - event.clientY - drag.offsetY, - ), + let cancelled = false; + setTextPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, ); - } + void invoke('read_local_project_text_preview', { + projectPath, + relativePath: focusedResourcePath, + }) + .then((preview) => { + if (!cancelled) { + setTextPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setTextPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + focusedResourceCategory, + focusedResourceContent, + focusedResourceId, + focusedResourceMediaType, + focusedResourcePath, + projectPath, + ]); - function handleResourceDialogPointerEnd( - event: ReactPointerEvent, - ) { - if (resourceDialogDragRef.current?.pointerId !== event.pointerId) { + useEffect(() => { + if ( + !focusedResourceId || + !focusedResourcePath || + !focusedResourceCategory || + (!focusedResourceIsExtendedArtMedia && !focusedResourceIsAudio) + ) { + setMediaPreview({ status: 'idle', resourceId: null }); + setMediaDuration(null); + return undefined; + } + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: '媒体预览需要在客户端内打开', + }); + return undefined; + } + + let cancelled = false; + setMediaDuration(null); + setMediaPreview((current) => + current.resourceId === focusedResourceId + ? current + : { status: 'loading', resourceId: focusedResourceId }, + ); + void invoke('read_local_project_media_preview', { + projectPath, + relativePath: focusedResourcePath, + category: focusedResourceCategory, + }) + .then((preview) => { + if (!cancelled) { + setMediaPreview({ + status: 'loaded', + resourceId: focusedResourceId, + preview, + }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setMediaPreview({ + status: 'failed', + resourceId: focusedResourceId, + error: mediaPreviewErrorMessage(error), + }); + } + }); + return () => { + cancelled = true; + }; + }, [ + focusedResourceCategory, + focusedResourceId, + focusedResourceIsAudio, + focusedResourceIsExtendedArtMedia, + focusedResourcePath, + projectPath, + ]); + + useLayoutEffect(() => { + if (suppressResourceFocusRestoreRef.current) { + suppressResourceFocusRestoreRef.current = false; + previousFocusedResourceIdRef.current = focusedResourceId; return; } - resourceDialogDragRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + 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; + } + const canvas = resourceCanvasRef.current; + if (canvas) { + canvas.scrollLeft = resourceListScrollRef.current.left; + canvas.scrollTop = resourceListScrollRef.current.top; + const triggerResourceId = resourceFocusTriggerIdRef.current; + 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, focusedResourceId]); + + const handleResourceSelect = useCallback((resourceId: string) => { + const canvas = resourceCanvasRef.current; + if (canvas) { + resourceListScrollRef.current = { + left: canvas.scrollLeft, + top: canvas.scrollTop, + }; + } + suppressResourceFocusRestoreRef.current = false; + resourceFocusTriggerIdRef.current = resourceId; + setSelectedResourceId(resourceId); + setFocusedResourceId(resourceId); + }, []); + + function closeResourceFocus() { + restoreResourceListScrollRef.current = true; + setFocusedResourceId(null); } function showRunView() { if (!runAvailable) { return; } + suppressResourceFocusRestoreRef.current = true; + restoreResourceListScrollRef.current = false; + resourceFocusTriggerIdRef.current = null; + setFocusedResourceId(null); setMode('run'); } return (
@@ -915,7 +1152,7 @@ export default function ProjectDevelopmentView({
- {mode === 'resources' ? ( + {mode === 'resources' && !focusedResource ? ( <>
- {!runAvailable ? ( + {!runAvailable && !focusedResource ? (

) : null} - {mode === 'resources' ? ( + {mode === 'resources' && focusedResource ? ( +

+
+ + + + + {categoryLabels[focusedResource.category]} + + {focusedResource.label} + + + + +
+
+ {focusedResourceIsImage ? ( +
+ {imagePreview.status === 'loaded' && + imagePreview.resourceId === focusedResource.id ? ( + {`${focusedResource.label} + setImagePreview({ + status: 'failed', + resourceId: focusedResource.id, + error: '图片内容无法解码,请重新生成或替换该资源', + }) + } + /> + ) : imagePreview.status === 'failed' && + imagePreview.resourceId === focusedResource.id ? ( +

{imagePreview.error}

+ ) : ( +

正在载入图片…

+ )} +
+ ) : null} + {focusedResourceIsExtendedArtMedia ? ( +
+ {mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id ? ( + mediaPreview.preview.mediaType.startsWith('video/') ? ( +
+ ) : null} + {focusedResourceIsAudio ? ( +
+ {mediaPreview.status === 'loaded' && + mediaPreview.resourceId === focusedResource.id ? ( +
+ ) : null} + {focusedResource.category === 'document' ? ( +
+ {textPreview.status === 'loaded' && + textPreview.resourceId === focusedResource.id ? ( + textPreview.preview.content.trim() ? ( + + ) : ( +

文档为空

+ ) + ) : textPreview.status === 'failed' && + textPreview.resourceId === focusedResource.id ? ( +

{textPreview.error}

+ ) : ( +

正在载入文档…

+ )} +
+ ) : null} +
+
+
资源路径
+
{focusedResource.path}
+
+
+
资源类型
+
{focusedPreviewMediaType}
+
+
+
资源来源
+
{focusedResource.sourceLabel}
+
+ {focusedResource.taskTitle ? ( +
+
来源任务
+
{focusedResource.taskTitle}
+
+ ) : null} + {focusedResourceIsAudio ? ( +
+
音频时长
+
{formatMediaDuration(mediaDuration)}
+
+ ) : null} + {focusedResource.version ? ( + <> +
+
版本 ID
+
{focusedResource.version.versionId}
+
+
+
项目修订
+
{focusedResource.version.projectRevision}
+
+
+
父版本
+
+ {focusedResource.version.parentVersionId ?? + '首个版本'} +
+
+
+
直接子版本
+
+ {focusedResource.version.childVersionIds.length > 0 + ? focusedResource.version.childVersionIds.join('、') + : '暂无'} +
+
+
+
创建原因
+
+ { + { + initial: '初始版本', + 'resource-replacement': '资源替换', + 'agent-revision': 'Agent 修订', + }[focusedResource.version.createdReason] + } +
+
+
+
创建时间
+
+ {formatVersionCreatedAt( + focusedResource.version.createdAt, + )} +
+
+
+
资源绑定
+
+ {focusedResource.version.resourceBindings.length > 0 + ? focusedResource.version.resourceBindings + .map( + (binding) => + `${binding.slotId} → ${binding.resourceId}`, + ) + .join(';') + : '暂无'} +
+
+ + ) : null} +
+
+
+ ) : mode === 'resources' ? (
) : ( @@ -1207,11 +1697,7 @@ export default function ProjectDevelopmentView({
-